mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6174b20226 | ||
|
|
4388807eee | ||
|
|
28d55e3fd8 | ||
|
|
16554508f5 | ||
|
|
84ff761544 | ||
|
|
a503b3a0ba | ||
|
|
df6194544b | ||
|
|
27b377186a | ||
|
|
31cab0d836 | ||
|
|
c8bdbad5a1 | ||
|
|
5b685861f1 | ||
|
|
9b1d02510c | ||
|
|
d69396323d | ||
|
|
30a9a060a5 | ||
|
|
65139c7b04 | ||
|
|
8c7c7e75ba | ||
|
|
1ded5ec253 | ||
|
|
f7f2c0c64a | ||
|
|
337bdd1a16 | ||
|
|
20a48e6af5 | ||
|
|
64f78875a9 | ||
|
|
75342a4a31 | ||
|
|
eee8036c52 | ||
|
|
0e1a5dde02 | ||
|
|
aa3b610109 | ||
|
|
318e6937d5 | ||
|
|
58e209d021 | ||
|
|
2ea36cf138 | ||
|
|
b83367e9d3 | ||
|
|
69810d5ab0 |
No files matched your search
@@ -0,0 +1,768 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
|
||||
options := Options{DeferThreshold: 10}
|
||||
if options.DeferThreshold != 10 {
|
||||
t.Fatalf("expected DeferThreshold 10, got %d", options.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultLoadedToolsField(t *testing.T) {
|
||||
result := ToolResult{LoadedTools: []string{"Alpha", "Beta"}}
|
||||
if len(result.LoadedTools) != 2 || result.LoadedTools[0] != "Alpha" || result.LoadedTools[1] != "Beta" {
|
||||
t.Fatalf("expected LoadedTools [Alpha Beta], got %#v", result.LoadedTools)
|
||||
}
|
||||
// Default zero value is nil for an ordinary result.
|
||||
if (ToolResult{}).LoadedTools != nil {
|
||||
t.Fatalf("expected nil LoadedTools by default")
|
||||
}
|
||||
}
|
||||
|
||||
// loadSignalTool returns Meta["load_tools"] like tool_search does, so we can
|
||||
// assert executeToolCall lifts it into ToolResult.LoadedTools.
|
||||
type loadSignalTool struct{ value string }
|
||||
|
||||
func (t loadSignalTool) Name() string { return "load_signal" }
|
||||
func (t loadSignalTool) Description() string { return "emits a load_tools signal" }
|
||||
func (t loadSignalTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t loadSignalTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t loadSignalTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
func (t loadSignalTool) RunWithOptions(_ context.Context, _ map[string]any, _ tools.RunOptions) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok", Meta: map[string]string{"load_tools": t.value}}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallLiftsLoadTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(loadSignalTool{value: " Alpha , Beta ,, "})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "load_signal", Arguments: ""},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatalf("unexpected abort error: %v", abortErr)
|
||||
}
|
||||
want := []string{"Alpha", "Beta"}
|
||||
if len(result.LoadedTools) != len(want) {
|
||||
t.Fatalf("expected LoadedTools %#v, got %#v", want, result.LoadedTools)
|
||||
}
|
||||
for i := range want {
|
||||
if result.LoadedTools[i] != want[i] {
|
||||
t.Fatalf("expected LoadedTools %#v, got %#v", want, result.LoadedTools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallNoLoadToolsMetaLeavesNil(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(secretEmittingTool{output: "plain"})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "leak", Arguments: ""},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatalf("unexpected abort error: %v", abortErr)
|
||||
}
|
||||
if result.LoadedTools != nil {
|
||||
t.Fatalf("expected nil LoadedTools for a tool with no load_tools meta, got %#v", result.LoadedTools)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDeferredTool is deferred-eligible (implements Deferred() bool) like an MCP
|
||||
// tool wrapper, so partitionTools counts and (when active) hides it.
|
||||
type fakeDeferredTool struct {
|
||||
name string
|
||||
desc string
|
||||
}
|
||||
|
||||
func (t fakeDeferredTool) Name() string { return t.name }
|
||||
func (t fakeDeferredTool) Description() string { return t.desc }
|
||||
func (t fakeDeferredTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (t fakeDeferredTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t fakeDeferredTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
func (t fakeDeferredTool) Deferred() bool { return true }
|
||||
|
||||
// fakeToolSearchTool stands in for component D's tool_search (a non-deferred
|
||||
// builtin) so the inactive path can assert it is dropped.
|
||||
type fakeToolSearchTool struct{}
|
||||
|
||||
func (fakeToolSearchTool) Name() string { return "tool_search" }
|
||||
func (fakeToolSearchTool) Description() string { return "load deferred tool schemas" }
|
||||
func (fakeToolSearchTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (fakeToolSearchTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
|
||||
}
|
||||
func (fakeToolSearchTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
options := Options{DeferThreshold: 0} // 0 => deferral disabled => inactive path.
|
||||
|
||||
// DeferThreshold 0 => deferral disabled => inactive path.
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty reminder on inactive path, got %q", reminder)
|
||||
}
|
||||
|
||||
// Strong byte-identity assertion: build the reference the LEGACY way (the old
|
||||
// toolDefinitions construction — every visible/advertised tool's full schema,
|
||||
// EXCEPT tool_search, alpha-sorted by name) and require an exact DeepEqual. This
|
||||
// pins that the inactive partition produces the pre-deferral output verbatim,
|
||||
// not merely the same set of names.
|
||||
reference := legacyToolDefinitions(registry, PermissionModeAuto, options)
|
||||
if !reflect.DeepEqual(exposed, reference) {
|
||||
t.Fatalf("inactive partition not byte-identical to legacy toolDefinitions:\n got %#v\nwant %#v", exposed, reference)
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: tool_search is dropped, the deferred tool keeps its full
|
||||
// schema, and only the expected names are present.
|
||||
for _, def := range exposed {
|
||||
if def.Name == "tool_search" {
|
||||
t.Fatalf("tool_search must be dropped on inactive path, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
wantNames := map[string]bool{"read_file": true, "mcp__srv__a": true}
|
||||
if len(exposed) != len(wantNames) {
|
||||
t.Fatalf("expected %d exposed tools, got %d: %#v", len(wantNames), len(exposed), exposed)
|
||||
}
|
||||
for _, def := range exposed {
|
||||
if !wantNames[def.Name] {
|
||||
t.Fatalf("unexpected exposed tool %q", def.Name)
|
||||
}
|
||||
if def.Name == "mcp__srv__a" && def.Parameters["type"] != "object" {
|
||||
t.Fatalf("expected full schema for deferred tool on inactive path, got %#v", def.Parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// legacyToolDefinitions reconstructs the PRE-deferral tool-list builder: every
|
||||
// tool that is visible (passes the operator filters) and advertised for the mode,
|
||||
// EXCEPT tool_search, rendered with its full schema and alpha-sorted by name. It
|
||||
// is the byte-identity reference for the inactive partition path.
|
||||
func legacyToolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0)
|
||||
for _, tool := range registry.All() {
|
||||
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
|
||||
continue
|
||||
}
|
||||
if tool.Name() == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
}
|
||||
sort.Slice(definitions, func(left, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions
|
||||
}
|
||||
|
||||
// Below-threshold-but-eligible (count < threshold) is also inactive.
|
||||
func TestPartitionToolsBelowThresholdInactive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "a"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__b", desc: "b"})
|
||||
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 10}, map[string]bool{})
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected empty reminder below threshold, got %q", reminder)
|
||||
}
|
||||
if len(exposed) != 2 {
|
||||
t.Fatalf("expected both deferred tools exposed below threshold, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 6(a): a deferred tool removed by DisabledTools must NOT count toward the
|
||||
// eligible total. With N deferred registered and threshold == N, disabling one
|
||||
// drops the surviving eligible count to N-1 < threshold, so the partition takes
|
||||
// the INACTIVE path (empty reminder, all VISIBLE tools exposed with full schemas,
|
||||
// the disabled one filtered out entirely).
|
||||
func TestPartitionToolsDisabledDeferredDropsBelowThresholdInactive(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{
|
||||
DeferThreshold: 2,
|
||||
DisabledTools: []string{"mcp__srv__beta"},
|
||||
}, map[string]bool{})
|
||||
|
||||
// Eligible drops to 1 (< threshold 2) => inactive: empty reminder.
|
||||
if reminder != "" {
|
||||
t.Fatalf("expected inactive path (empty reminder) when a disable drops eligible below threshold, got %q", reminder)
|
||||
}
|
||||
// Only the surviving visible tool is exposed, with its FULL schema; the disabled
|
||||
// tool is filtered out entirely.
|
||||
if len(exposed) != 1 || exposed[0].Name != "mcp__srv__alpha" {
|
||||
t.Fatalf("expected only mcp__srv__alpha exposed on inactive path, got %#v", exposed)
|
||||
}
|
||||
if exposed[0].Parameters["type"] != "object" {
|
||||
t.Fatalf("surviving deferred tool must keep its full schema on inactive path, got %#v", exposed[0].Parameters)
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 6(b): with deferral ACTIVE, a DisabledTools-hidden deferred tool must never
|
||||
// appear in the reminder NOR be exposed — it is filtered out before the partition
|
||||
// even considers it.
|
||||
func TestPartitionToolsActiveExcludesDisabledDeferredFromReminderAndExposed(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(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{
|
||||
DeferThreshold: 2,
|
||||
DisabledTools: []string{"mcp__srv__beta"},
|
||||
}, map[string]bool{})
|
||||
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected active path with a non-empty reminder for the unloaded tools")
|
||||
}
|
||||
if strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("disabled deferred tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
for _, def := range exposed {
|
||||
if def.Name == "mcp__srv__beta" {
|
||||
t.Fatalf("disabled deferred tool must not be exposed, got %#v", exposed)
|
||||
}
|
||||
}
|
||||
// The two surviving deferred tools are hidden (unloaded) and named in the reminder.
|
||||
if !strings.Contains(reminder, "mcp__srv__alpha") || !strings.Contains(reminder, "mcp__srv__gamma") {
|
||||
t.Fatalf("reminder must list the surviving unloaded deferred tools, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
|
||||
loaded := map[string]bool{"mcp__srv__alpha": true}
|
||||
|
||||
// 2 eligible deferred tools, threshold 2 => active.
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, loaded)
|
||||
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
exposedNames[def.Name] = true
|
||||
}
|
||||
if !exposedNames["read_file"] {
|
||||
t.Fatalf("expected builtin read_file exposed, got %#v", exposed)
|
||||
}
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("expected tool_search exposed on active path, got %#v", exposed)
|
||||
}
|
||||
if !exposedNames["mcp__srv__alpha"] {
|
||||
t.Fatalf("expected loaded deferred tool exposed, got %#v", exposed)
|
||||
}
|
||||
if exposedNames["mcp__srv__beta"] {
|
||||
t.Fatalf("unloaded deferred tool must be hidden from exposed, got %#v", exposed)
|
||||
}
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected a non-empty reminder for the hidden tool")
|
||||
}
|
||||
if !strings.Contains(reminder, "mcp__srv__beta") {
|
||||
t.Fatalf("expected reminder to list the hidden tool, got %q", reminder)
|
||||
}
|
||||
if strings.Contains(reminder, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the reminder, got %q", reminder)
|
||||
}
|
||||
}
|
||||
|
||||
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"] {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLoadsDeferredToolThenAdvertisesNextTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
// load_signal asks the loop to load mcp__srv__alpha next turn.
|
||||
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
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{ // turn 2: final answer
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
DeferThreshold: 2, // 2 deferred tools => active
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected two turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// Turn 1: alpha is hidden (not advertised) and the reminder is a trailing user msg.
|
||||
turn1Tools := map[string]bool{}
|
||||
for _, def := range provider.requests[0].Tools {
|
||||
turn1Tools[def.Name] = true
|
||||
}
|
||||
if turn1Tools["mcp__srv__alpha"] {
|
||||
t.Fatalf("turn 1 must not advertise the not-yet-loaded deferred tool")
|
||||
}
|
||||
last1 := provider.requests[0].Messages[len(provider.requests[0].Messages)-1]
|
||||
if last1.Role != zeroruntime.MessageRoleUser || !strings.Contains(last1.Content, "tool_search") {
|
||||
t.Fatalf("turn 1 request must end with the deferred-tools reminder, got role=%s content=%q", last1.Role, last1.Content)
|
||||
}
|
||||
|
||||
// Turn 2: alpha is now loaded and advertised with a full schema.
|
||||
turn2Tools := map[string]bool{}
|
||||
for _, def := range provider.requests[1].Tools {
|
||||
turn2Tools[def.Name] = true
|
||||
}
|
||||
if !turn2Tools["mcp__srv__alpha"] {
|
||||
t.Fatalf("turn 2 must advertise the loaded deferred tool, got %#v", provider.requests[1].Tools)
|
||||
}
|
||||
if turn2Tools["mcp__srv__beta"] {
|
||||
t.Fatalf("beta was never loaded; it must stay hidden in turn 2")
|
||||
}
|
||||
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder drives a mid-run
|
||||
// context-limit error that triggers reactive compaction+retry while deferral is
|
||||
// ACTIVE and a deferred tool is already loaded. The retried turn must NOT be
|
||||
// degraded to the empty-loaded/no-reminder state: it must still advertise the
|
||||
// loaded tool's FULL schema and carry the <system-reminder> for the hidden tool.
|
||||
func TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
// load_signal asks the loop to load mcp__srv__alpha next turn.
|
||||
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)
|
||||
// 1: turn 2 — emits a context-limit error MID-stream -> reactive recover
|
||||
// 2: the summarize call inside Compact (must return non-empty text)
|
||||
// 3: the RETRY request rebuilt after compaction — asserted below
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{ // turn 1: call load_signal
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{ // turn 2: mid-stream context-limit error -> reactive compaction
|
||||
{Type: zeroruntime.StreamEventError, Error: "prompt is too long: 250000 tokens > 200000 maximum"},
|
||||
},
|
||||
{ // summarize call inside Compact
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{ // retry of turn 2 after compaction: final answer
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
// A large user prompt so the elided middle is big enough that reactive
|
||||
// compaction actually shrinks the history (recover only retries when it can
|
||||
// shrink). ContextWindow is set high enough that PROACTIVE compaction (which
|
||||
// fires at 0.8 * window at the top of a turn) does NOT trigger first — so the
|
||||
// only summarize call is the reactive one, keeping the request sequence
|
||||
// predictable: turn1, errored turn2, summarize, retry.
|
||||
bigPrompt := strings.Repeat("work on this task. ", 2000)
|
||||
result, err := Run(context.Background(), bigPrompt, provider, Options{
|
||||
Registry: registry,
|
||||
DeferThreshold: 2, // 2 deferred tools => active
|
||||
ContextWindow: 1_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer from the retried turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
// 4 provider calls: turn1, errored turn2, summarize, retry.
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 provider requests (turn1, errored turn2, summarize, retry), got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
retry := provider.requests[3]
|
||||
|
||||
// The retry must advertise the loaded deferred tool with its FULL schema —
|
||||
// NOT re-hide it as the empty-loaded partition would.
|
||||
var alpha *zeroruntime.ToolDefinition
|
||||
for i := range retry.Tools {
|
||||
if retry.Tools[i].Name == "mcp__srv__alpha" {
|
||||
alpha = &retry.Tools[i]
|
||||
}
|
||||
if retry.Tools[i].Name == "mcp__srv__beta" {
|
||||
t.Fatalf("retry must keep the never-loaded deferred tool hidden, got %#v", retry.Tools)
|
||||
}
|
||||
}
|
||||
if alpha == nil {
|
||||
t.Fatalf("retry must still advertise the loaded deferred tool mcp__srv__alpha, got %#v", retry.Tools)
|
||||
}
|
||||
if alpha.Parameters["type"] != "object" {
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
// The retry must carry the deferred-tools reminder as its trailing user
|
||||
// message (the hidden tool is mcp__srv__beta) — not the no-reminder state.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The reminder must NOT persist into the returned message history, even on
|
||||
// the reactive-retry path.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// connectErrorProvider returns a connect-time error (StreamCompletion itself
|
||||
// returns a non-nil error) on the request at index errAtRequest, exercising the
|
||||
// FIRST reactive-recovery block in the loop (loop.go:99). Every other request
|
||||
// streams the corresponding turn's events. It mirrors mockProvider's recording so
|
||||
// the rebuilt retry request can be asserted.
|
||||
type connectErrorProvider struct {
|
||||
turns [][]zeroruntime.StreamEvent
|
||||
errAtRequest int
|
||||
errText string
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func (provider *connectErrorProvider) StreamCompletion(_ context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
index := len(provider.requests) - 1
|
||||
if index == provider.errAtRequest {
|
||||
return nil, errors.New(provider.errText)
|
||||
}
|
||||
events := []zeroruntime.StreamEvent{{Type: zeroruntime.StreamEventDone}}
|
||||
if index < len(provider.turns) {
|
||||
events = provider.turns[index]
|
||||
}
|
||||
ch := make(chan zeroruntime.StreamEvent, len(events))
|
||||
for _, event := range events {
|
||||
ch <- event
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder mirrors
|
||||
// TestRunReactiveRetryKeepsLoadedDeferredToolAndReminder but drives the
|
||||
// CONNECT-TIME error path: StreamCompletion itself returns a non-nil
|
||||
// context-limit error (rather than a mid-stream StreamEventError). With deferral
|
||||
// ACTIVE and a deferred tool already loaded, the rebuilt retry request must still
|
||||
// advertise the loaded tool's FULL schema AND carry the deferred-tools reminder —
|
||||
// i.e. the first reactive block reuses exposed/reminder, not the empty-loaded
|
||||
// partition.
|
||||
func TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndReminder(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
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)
|
||||
// 1: turn 2 — StreamCompletion returns a connect-time context-limit error
|
||||
// 2: the summarize call inside Compact (must return non-empty text)
|
||||
// 3: the RETRY request rebuilt after compaction — asserted below
|
||||
provider := &connectErrorProvider{
|
||||
errAtRequest: 1,
|
||||
errText: "prompt is too long: 250000 tokens > 200000 maximum",
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{ // turn 1: call load_signal
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "load_signal"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
nil, // index 1: replaced by the connect-time error
|
||||
{ // index 2: summarize call inside Compact
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{ // index 3: retry of turn 2 after compaction: final answer
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
bigPrompt := strings.Repeat("work on this task. ", 2000)
|
||||
result, err := Run(context.Background(), bigPrompt, provider, Options{
|
||||
Registry: registry,
|
||||
DeferThreshold: 2, // 2 deferred tools => active
|
||||
ContextWindow: 1_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run returned error: %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected final answer from the retried turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
// 4 provider calls: turn1, errored-connect turn2, summarize, retry.
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 provider requests (turn1, errored turn2, summarize, retry), got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
retry := provider.requests[3]
|
||||
|
||||
// The retry must advertise the loaded deferred tool with its FULL schema and
|
||||
// keep the never-loaded one hidden.
|
||||
var alpha *zeroruntime.ToolDefinition
|
||||
for i := range retry.Tools {
|
||||
if retry.Tools[i].Name == "mcp__srv__alpha" {
|
||||
alpha = &retry.Tools[i]
|
||||
}
|
||||
if retry.Tools[i].Name == "mcp__srv__beta" {
|
||||
t.Fatalf("retry must keep the never-loaded deferred tool hidden, got %#v", retry.Tools)
|
||||
}
|
||||
}
|
||||
if alpha == nil {
|
||||
t.Fatalf("retry must still advertise the loaded deferred tool mcp__srv__alpha, got %#v", retry.Tools)
|
||||
}
|
||||
if alpha.Parameters["type"] != "object" {
|
||||
t.Fatalf("retry must advertise the loaded tool's FULL schema, got %#v", alpha.Parameters)
|
||||
}
|
||||
|
||||
// The retry must carry the deferred-tools reminder as its trailing user message.
|
||||
last := retry.Messages[len(retry.Messages)-1]
|
||||
if last.Role != zeroruntime.MessageRoleUser || !strings.Contains(last.Content, "tool_search") {
|
||||
t.Fatalf("retry request must end with the deferred-tools reminder, got role=%s content=%q", last.Role, last.Content)
|
||||
}
|
||||
if !strings.Contains(last.Content, "mcp__srv__beta") {
|
||||
t.Fatalf("retry reminder must list the still-hidden tool mcp__srv__beta, got %q", last.Content)
|
||||
}
|
||||
if strings.Contains(last.Content, "mcp__srv__alpha") {
|
||||
t.Fatalf("loaded tool must not appear in the retry reminder, got %q", last.Content)
|
||||
}
|
||||
|
||||
// The reminder must NOT persist into the returned message history.
|
||||
for _, m := range result.Messages {
|
||||
if m.Role == zeroruntime.MessageRoleUser && strings.Contains(m.Content, "Call tool_search") {
|
||||
t.Fatalf("the deferred-tools reminder must not be persisted in result.Messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllowlistedDeferredToolsKeepToolSearchReachable is the FIX 1+2 dead-end
|
||||
// regression: N deferred tools are registered alongside tool_search, the operator
|
||||
// allowlists ONLY the N deferred names (NOT tool_search), and DeferThreshold == N.
|
||||
// Deferral must ACTIVATE, the partition must STILL expose tool_search (the gateway
|
||||
// to the allowlisted tools), and a tool_search call must be DISPATCH-ALLOWED — so
|
||||
// the reminder never points the model at an unreachable tool.
|
||||
func TestAllowlistedDeferredToolsKeepToolSearchReachable(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
deferredNames := []string{"mcp__srv__alpha", "mcp__srv__beta"}
|
||||
for _, name := range deferredNames {
|
||||
registry.Register(fakeDeferredTool{name: name, desc: name + " tool"})
|
||||
}
|
||||
// The REAL tool_search (so partitionTools can look it up by name and dispatch
|
||||
// can run it through registry.RunWithOptions).
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
|
||||
options := Options{
|
||||
DeferThreshold: len(deferredNames), // threshold == N => active
|
||||
// Allowlist the deferred tools but NOT tool_search.
|
||||
EnabledTools: append([]string{}, deferredNames...),
|
||||
}
|
||||
|
||||
exposed, reminder := partitionTools(registry, PermissionModeAuto, options, map[string]bool{})
|
||||
|
||||
// Deferral active => non-empty reminder advertising the hidden deferred tools.
|
||||
if reminder == "" {
|
||||
t.Fatalf("expected deferral active (non-empty reminder) at threshold N, got empty")
|
||||
}
|
||||
if !strings.Contains(reminder, "tool_search") {
|
||||
t.Fatalf("reminder must instruct the model to call tool_search, got %q", reminder)
|
||||
}
|
||||
|
||||
// FIX 2(a): tool_search is exposed even though the allowlist omits it.
|
||||
exposedNames := map[string]bool{}
|
||||
for _, def := range exposed {
|
||||
exposedNames[def.Name] = true
|
||||
}
|
||||
if !exposedNames["tool_search"] {
|
||||
t.Fatalf("tool_search must be exposed on the active path despite the allowlist omitting it, got %#v", exposed)
|
||||
}
|
||||
// The allowlisted-but-unloaded deferred tools are hidden (not exposed).
|
||||
for _, name := range deferredNames {
|
||||
if exposedNames[name] {
|
||||
t.Fatalf("unloaded deferred tool %q must be hidden when active, got %#v", name, exposed)
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 2(b): a tool_search call must be DISPATCH-ALLOWED (not rejected by the
|
||||
// allowlist that omits it). It runs through the registry and reports a load.
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
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("tool_search call was rejected: status=%s output=%q", result.Status, result.Output)
|
||||
}
|
||||
if result.DenialReason == DenialFiltered {
|
||||
t.Fatalf("tool_search must not be denied by the allowlist, got DenialFiltered: %q", result.Output)
|
||||
}
|
||||
if len(result.LoadedTools) != 1 || result.LoadedTools[0] != "mcp__srv__alpha" {
|
||||
t.Fatalf("tool_search call must load mcp__srv__alpha, got LoadedTools=%#v", result.LoadedTools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisabledToolSearchStaysHiddenAndRejectedWhenActive verifies an explicit
|
||||
// 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) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
|
||||
options := Options{
|
||||
DeferThreshold: 2,
|
||||
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))
|
||||
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)
|
||||
}
|
||||
|
||||
// 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: `{}`},
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+209
-8
@@ -33,6 +33,12 @@ 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.
|
||||
|
||||
@@ -61,6 +67,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
guards := newGuardState()
|
||||
compactor := newCompactionState(options)
|
||||
|
||||
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
||||
// during THIS run. It is consulted by partitionTools each turn to expose a
|
||||
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
||||
loaded := map[string]bool{}
|
||||
|
||||
result := Result{Messages: copyMessages(messages)}
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
@@ -70,9 +81,19 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
messages = compactor.maybeCompact(ctx, provider, messages)
|
||||
|
||||
exposed, reminder := partitionTools(registry, permissionMode, options, loaded)
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefinitions(registry, permissionMode, options),
|
||||
Tools: exposed,
|
||||
}
|
||||
if reminder != "" {
|
||||
// Append to the per-turn request copy only — NEVER to persistent
|
||||
// messages — so the reminder refreshes each turn and never accumulates
|
||||
// in the saved transcript.
|
||||
request.Messages = append(request.Messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
@@ -91,9 +112,23 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, retryErr
|
||||
}
|
||||
// Rebuild from the compacted messages but reuse the SAME active-mode
|
||||
// partition (exposed) and reminder computed for this turn: they depend
|
||||
// on registry+loaded, not on the messages, so they stay valid after
|
||||
// compaction. Using the bare toolDefinitions here would route through an
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool
|
||||
// and dropping the reminder when deferral is active.
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefinitions(registry, permissionMode, options),
|
||||
Tools: exposed,
|
||||
}
|
||||
if reminder != "" {
|
||||
// Append to the per-turn retry copy only — NEVER to persistent
|
||||
// messages — matching the main path's reminder-not-persisted invariant.
|
||||
request.Messages = append(request.Messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
}
|
||||
@@ -117,9 +152,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, retryErr
|
||||
}
|
||||
// Reuse the SAME active-mode partition (exposed) and reminder from this
|
||||
// turn rather than the bare toolDefinitions: exposed/reminder depend on
|
||||
// registry+loaded (not the messages), so they stay valid after compaction.
|
||||
// Routing through an empty-loaded partition here would re-hide every
|
||||
// already-loaded deferred tool and drop the reminder when deferral is active.
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefinitions(registry, permissionMode, options),
|
||||
Tools: exposed,
|
||||
}
|
||||
if reminder != "" {
|
||||
// Append to the per-turn retry copy only — NEVER to persistent
|
||||
// messages — matching the main path's reminder-not-persisted invariant.
|
||||
retryRequest.Messages = append(retryRequest.Messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
retryStream, retryStreamErr := provider.StreamCompletion(ctx, retryRequest)
|
||||
if retryStreamErr != nil {
|
||||
@@ -193,6 +241,11 @@ 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)
|
||||
@@ -201,6 +254,15 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
// Union the deferred tools this result asked to load into the per-run
|
||||
// set BEFORE any abort/stop/guard branch, so a load that coincides with
|
||||
// a turn-ending result is still recorded for the next turn's partition.
|
||||
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,
|
||||
@@ -252,6 +314,32 @@ 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
|
||||
@@ -315,7 +403,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
if !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
|
||||
// tool_search is the gateway to the allowlisted deferred tools, so a non-empty
|
||||
// EnabledTools allowlist that omits it must NOT reject the call — otherwise the
|
||||
// reminder points the model at a tool the dispatch gate rejects (an inescapable
|
||||
// dead-end). The allowlist is exempted; an explicit DisabledTools entry for
|
||||
// tool_search is still honored (only the allowlist is exempted, not the denylist).
|
||||
toolSearchAllowed := call.Name == tools.ToolSearchToolName && !containsToolName(options.DisabledTools, tools.ToolSearchToolName)
|
||||
if !toolSearchAllowed && !ToolAllowedByFilters(call.Name, options.EnabledTools, options.DisabledTools) {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
@@ -395,6 +489,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
// Forward the run's operator tool filters so a filter-aware tool
|
||||
// (tool_search) never discloses or loads an operator-hidden deferred tool.
|
||||
EnabledTools: options.EnabledTools,
|
||||
DisabledTools: options.DisabledTools,
|
||||
// The sandbox decision (if any) is returned synchronously on the Result and
|
||||
// used here for permission event building.
|
||||
})
|
||||
@@ -417,6 +515,12 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
Redacted: result.Redacted,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -799,24 +903,102 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
|
||||
}
|
||||
}
|
||||
|
||||
func toolDefinitions(registry *tools.Registry, permissionMode PermissionMode, options Options) []zeroruntime.ToolDefinition {
|
||||
// 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
|
||||
// (dropped so it is never advertised when it cannot help), and the reminder is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and its compact
|
||||
// line goes into the reminder. Non-deferred tools (including tool_search) are
|
||||
// always exposed. The exposed slice is alpha-sorted by name, matching the legacy
|
||||
// order so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
registeredTools := registry.All()
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(registeredTools))
|
||||
|
||||
visible := make([]tools.Tool, 0, len(registeredTools))
|
||||
eligible := 0
|
||||
for _, tool := range registeredTools {
|
||||
if !ToolVisible(tool, permissionMode, options.EnabledTools, options.DisabledTools) {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, tool)
|
||||
if tools.IsDeferred(tool) {
|
||||
eligible++
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
|
||||
exposedNames := make(map[string]bool, len(visible))
|
||||
var hiddenLines []string
|
||||
for _, tool := range visible {
|
||||
name := tool.Name()
|
||||
deferred := tools.IsDeferred(tool)
|
||||
|
||||
if !active {
|
||||
// Inactive: byte-identical to legacy, but tool_search is never advertised.
|
||||
if name == tools.ToolSearchToolName {
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
exposedNames[name] = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Active path.
|
||||
if deferred && !loaded[name] {
|
||||
hiddenLines = append(hiddenLines, tools.DeferredLine(tool))
|
||||
continue
|
||||
}
|
||||
definitions = append(definitions, zeroruntime.ToolDefinition{
|
||||
Name: tool.Name(),
|
||||
Name: name,
|
||||
Description: tool.Description(),
|
||||
Parameters: schemaToRuntimeMap(tool.Parameters()),
|
||||
})
|
||||
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
|
||||
// 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()),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(definitions, func(left int, right int) bool {
|
||||
return definitions[left].Name < definitions[right].Name
|
||||
})
|
||||
return definitions
|
||||
|
||||
reminder := ""
|
||||
if active {
|
||||
reminder = tools.BuildDeferredReminder(hiddenLines)
|
||||
}
|
||||
return definitions, reminder
|
||||
}
|
||||
|
||||
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
|
||||
@@ -937,6 +1119,25 @@ func stopReasonFromToolResult(result ToolResult) StopReason {
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadedToolsFromResult extracts the deferred-tool names a tool (tool_search)
|
||||
// asked the loop to expose next turn from Meta["load_tools"] (comma-separated).
|
||||
// It trims each name and drops empties; returns nil when the key is absent or
|
||||
// yields no names, so an ordinary result keeps a nil LoadedTools. Mirrors the
|
||||
// Meta-driven control signal read by stopReasonFromToolResult.
|
||||
func loadedToolsFromResult(meta map[string]string) []string {
|
||||
raw := meta["load_tools"]
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
names = append(names, trimmed)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// appendAbortedToolResults adds a placeholder tool-result message for each of
|
||||
// the given (unexecuted) tool calls, so every advertised tool_use keeps a
|
||||
// matching tool_result when the loop halts a turn before all calls have run.
|
||||
|
||||
@@ -54,6 +54,15 @@ type ToolResult struct {
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
// LoadedTools carries the deferred-tool names a tool_search call asked the
|
||||
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
|
||||
// ordinary tool result; only tool_search populates it.
|
||||
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.
|
||||
@@ -125,6 +134,12 @@ type AskUserResponse struct {
|
||||
|
||||
type Options struct {
|
||||
MaxTurns int
|
||||
// DeferThreshold activates deferred MCP-tool loading: when the number of
|
||||
// deferred-eligible visible tools is >= this value (and it is > 0), their
|
||||
// full schemas are withheld and advertised as compact lines via tool_search.
|
||||
// 0 (or below the eligible count) keeps every tool eager — byte-identical to
|
||||
// the pre-deferral behavior.
|
||||
DeferThreshold int
|
||||
// Specialist/sub-agent metadata is carried through exec now and consumed by
|
||||
// the specialist runtime in later slices.
|
||||
SessionID string
|
||||
@@ -166,6 +181,13 @@ 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 {
|
||||
|
||||
+37
-10
@@ -181,6 +181,8 @@ 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":
|
||||
@@ -326,6 +328,27 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
|
||||
return writeAppError(stderr, err.Error(), 1)
|
||||
}
|
||||
defer closeMCPRuntime(stderr, mcpRuntime)
|
||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||
// files nor run shell. Ask advertises them and routes each through the existing
|
||||
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
|
||||
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
|
||||
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
|
||||
//
|
||||
// Resolve the effective mode BEFORE the deferral gate below so the registration
|
||||
// count uses the SAME permission mode the agent loop's partition will use; an
|
||||
// empty mode here would mis-gate prompt-advertised deferred tools.
|
||||
if permissionMode == "" {
|
||||
permissionMode = agent.PermissionModeAsk
|
||||
}
|
||||
// Activate deferred MCP-tool loading for the interactive run only when the
|
||||
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
|
||||
// The registry is complete (core + specialist + MCP) here, so the count is
|
||||
// accurate; below threshold this is a no-op and the surface is unchanged. The
|
||||
// interactive surface applies no operator tool filters, so enabled/disabled are
|
||||
// nil — matching the AgentOptions below.
|
||||
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
|
||||
sandboxStore, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
|
||||
@@ -336,16 +359,6 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
|
||||
Store: sandboxStore,
|
||||
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
|
||||
})
|
||||
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
|
||||
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
|
||||
// apply_patch) would never be offered to the model — the TUI could neither edit
|
||||
// files nor run shell. Ask advertises them and routes each through the existing
|
||||
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
|
||||
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
|
||||
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
|
||||
if permissionMode == "" {
|
||||
permissionMode = agent.PermissionModeAsk
|
||||
}
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
@@ -362,10 +375,12 @@ func runInteractiveTUIWithSkin(stderr io.Writer, deps appDeps, skin string, perm
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: string(sandbox.AutonomyLow),
|
||||
Sandbox: sandboxEngine,
|
||||
DeferThreshold: resolved.Tools.DeferThreshold,
|
||||
},
|
||||
PermissionMode: permissionMode,
|
||||
Skin: skin,
|
||||
ThemeDark: true,
|
||||
Notify: resolved.Notify,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -393,6 +408,9 @@ 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
|
||||
}
|
||||
@@ -444,6 +462,7 @@ 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
|
||||
@@ -487,6 +506,10 @@ 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
|
||||
@@ -512,6 +535,10 @@ 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
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
)
|
||||
|
||||
// cliFakeDeferredTool is deferred-eligible (implements Deferred() bool), mirroring
|
||||
// an MCP registry tool, so it counts toward the deferral threshold.
|
||||
type cliFakeDeferredTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t cliFakeDeferredTool) Name() string { return t.name }
|
||||
func (t cliFakeDeferredTool) Description() string { return "fake deferred tool" }
|
||||
func (t cliFakeDeferredTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (t cliFakeDeferredTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t cliFakeDeferredTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
func (t cliFakeDeferredTool) Deferred() bool { return true }
|
||||
|
||||
func registryHasToolSearch(registry *tools.Registry) bool {
|
||||
_, ok := registry.Get("tool_search")
|
||||
return ok
|
||||
}
|
||||
|
||||
func TestRegisterToolSearchIfEligibleRegistersAtThreshold(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for i := 0; i < 3; i++ {
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_srv_t" + string(rune('a'+i))})
|
||||
}
|
||||
|
||||
registerToolSearchIfEligible(registry, 3, agent.PermissionModeAuto, nil, nil)
|
||||
|
||||
if !registryHasToolSearch(registry) {
|
||||
t.Fatal("expected tool_search registered when eligible count == threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterToolSearchIfEligibleSkipsBelowThreshold(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_srv_ta"})
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_srv_tb"})
|
||||
// A plain (non-deferred) MCP-named tool must NOT count toward eligibility.
|
||||
registry.Register(cliFakeMCPRegistryTool{})
|
||||
|
||||
registerToolSearchIfEligible(registry, 3, agent.PermissionModeAuto, nil, nil)
|
||||
|
||||
if registryHasToolSearch(registry) {
|
||||
t.Fatal("expected no tool_search when eligible count (2) < threshold (3)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for i := 0; i < 5; i++ {
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_srv_t" + string(rune('a'+i))})
|
||||
}
|
||||
|
||||
registerToolSearchIfEligible(registry, 0, agent.PermissionModeAuto, nil, nil)
|
||||
|
||||
if registryHasToolSearch(registry) {
|
||||
t.Fatal("expected no tool_search when threshold is 0 (disabled)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeferredEligibleCountIgnoresCoreTools(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
// newCoreRegistry holds only built-ins; none implement Deferred().
|
||||
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got != 0 {
|
||||
t.Fatalf("deferredEligibleCount(core) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 1: a deferred tool the operator hid via --disabled-tools must NOT count
|
||||
// toward the visible-deferred total, so registration agrees with the loop's
|
||||
// activation gate. Two deferred + threshold 2 normally registers tool_search;
|
||||
// disabling one drops the visible count to 1 (< 2) so it must NOT register.
|
||||
func TestRegisterToolSearchSkipsWhenDisabledDropsVisibleBelowThreshold(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_srv_ta"})
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_srv_tb"})
|
||||
|
||||
registerToolSearchIfEligible(registry, 2, agent.PermissionModeAuto, nil, []string{"mcp_srv_tb"})
|
||||
|
||||
if registryHasToolSearch(registry) {
|
||||
t.Fatal("expected no tool_search: a disabled deferred tool must not count toward the visible-deferred total")
|
||||
}
|
||||
}
|
||||
|
||||
// FIX 3: validateExecToolFilters must treat tool_search as always-valid even
|
||||
// though it is not registered yet at validation time (it is registered later only
|
||||
// when deferral activates). Listing it in --enabled-tools/--disabled-tools must
|
||||
// not raise "Unknown tool".
|
||||
func TestValidateExecToolFiltersAllowsToolSearch(t *testing.T) {
|
||||
registry := newCoreRegistry(t.TempDir())
|
||||
// tool_search is NOT registered in this registry — it would be added later.
|
||||
if _, present := registry.Get(tools.ToolSearchToolName); present {
|
||||
t.Fatalf("precondition: tool_search must not be registered yet")
|
||||
}
|
||||
|
||||
if err := validateExecToolFilters(execOptions{enabledTools: []string{tools.ToolSearchToolName}}, registry); err != nil {
|
||||
t.Fatalf("--enabled-tools tool_search must validate, got error: %v", err)
|
||||
}
|
||||
if err := validateExecToolFilters(execOptions{disabledTools: []string{tools.ToolSearchToolName}}, registry); err != nil {
|
||||
t.Fatalf("--disabled-tools tool_search must validate, got error: %v", err)
|
||||
}
|
||||
// A genuinely unknown tool still errors.
|
||||
if err := validateExecToolFilters(execOptions{enabledTools: []string{"definitely_not_a_tool"}}, registry); err == nil {
|
||||
t.Fatal("expected an Unknown tool error for an unregistered, non-tool_search name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch verifies that
|
||||
// `exec --list-tools` lists the core + MCP tools WITHOUT tool_search. This is
|
||||
// independent of the deferral threshold: --list-tools short-circuits and returns
|
||||
// before resolveConfig and registerToolSearchIfEligible ever run (see exec.go),
|
||||
// so tool_search is never registered on this path. The threshold gate itself is
|
||||
// exercised by TestRegisterToolSearchIfEligible{RegistersAtThreshold,
|
||||
// SkipsBelowThreshold,SkipsWhenThresholdZero} and the end-to-end at-threshold
|
||||
// path in TestTUIRunThreadsDeferThresholdAndRegistersToolSearch.
|
||||
func TestRunExecListToolsAdvertisesMCPToolsWithoutToolSearch(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
exitCode := runWithDeps([]string{"exec", "--list-tools"}, &stdout, &stderr, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{}, errors.New("provider should not be resolved for --list-tools")
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
registerMCPTools: func(_ context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
registry.Register(cliFakeMCPRegistryTool{})
|
||||
return closeFunc(func() error { return nil }), nil
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "mcp_docs_lookup") {
|
||||
t.Fatalf("expected MCP tool advertised by --list-tools, got %q", out)
|
||||
}
|
||||
// --list-tools never registers tool_search (it returns before the threshold
|
||||
// gate runs); the threshold logic is verified by the unit/E2E tests named above.
|
||||
if strings.Contains(out, "tool_search") {
|
||||
t.Fatalf("expected NO tool_search from --list-tools, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIRunThreadsDeferThresholdAndRegistersToolSearch(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
var captured agent.Options
|
||||
var capturedRegistry *tools.Registry
|
||||
|
||||
// Empty args route runWithDeps to the interactive TUI (runInteractiveTUIWithSkin).
|
||||
// There is no "--tui" flag; that arg would hit the unknown-command path.
|
||||
exitCode := runWithDeps([]string{}, io.Discard, io.Discard, appDeps{
|
||||
getwd: func() (string, error) { return cwd, nil },
|
||||
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{
|
||||
Provider: config.ProviderProfile{
|
||||
Name: "p",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "http://127.0.0.1/v1",
|
||||
Model: "m",
|
||||
},
|
||||
Tools: config.ToolsConfig{DeferThreshold: 2},
|
||||
}, nil
|
||||
},
|
||||
resolveMCPConfig: func(string) (config.MCPConfig, error) {
|
||||
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
||||
"docs": {Type: "stdio", Command: "docs-mcp"},
|
||||
}}, nil
|
||||
},
|
||||
newMCPStore: func() (*mcp.PermissionStore, error) { return nil, nil },
|
||||
registerMCPTools: func(_ context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_docs_ta"})
|
||||
registry.Register(cliFakeDeferredTool{name: "mcp_docs_tb"})
|
||||
return closeFunc(func() error { return nil }), nil
|
||||
},
|
||||
runTUI: func(_ context.Context, options tui.Options) int {
|
||||
captured = options.AgentOptions
|
||||
capturedRegistry = options.Registry
|
||||
return exitSuccess
|
||||
},
|
||||
})
|
||||
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d", exitSuccess, exitCode)
|
||||
}
|
||||
if captured.DeferThreshold != 2 {
|
||||
t.Fatalf("AgentOptions.DeferThreshold = %d, want 2", captured.DeferThreshold)
|
||||
}
|
||||
if capturedRegistry == nil {
|
||||
t.Fatal("expected registry passed to TUI")
|
||||
}
|
||||
if _, ok := capturedRegistry.Get("tool_search"); !ok {
|
||||
t.Fatal("expected tool_search registered for TUI run at/above threshold")
|
||||
}
|
||||
}
|
||||
+180
-7
@@ -14,11 +14,14 @@ 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"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
@@ -57,6 +60,9 @@ type execOptions struct {
|
||||
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
|
||||
modelProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
specReasoningEffort string
|
||||
maxTurns int
|
||||
cwd string
|
||||
inputFormat execInputFormat
|
||||
@@ -78,6 +84,16 @@ 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 {
|
||||
@@ -128,6 +144,14 @@ 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
|
||||
@@ -141,11 +165,17 @@ 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())
|
||||
}
|
||||
@@ -177,8 +207,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
modelRegistry, _ := modelregistry.DefaultRegistry()
|
||||
|
||||
overrides := config.Overrides{}
|
||||
if options.model != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, options.model)
|
||||
modelOverride := options.model
|
||||
if options.useSpec && options.specModel != "" {
|
||||
modelOverride = options.specModel
|
||||
}
|
||||
if modelOverride != "" {
|
||||
resolvedModel, notice := resolveSelectedModel(modelRegistry, modelOverride)
|
||||
overrides.Provider.Model = resolvedModel
|
||||
if notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
@@ -196,6 +230,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if !config.HasProviderProfile(resolved.Provider) {
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", "No provider configured. Set OPENAI_MODEL/OPENAI_API_KEY or add .zero/config.json.")
|
||||
}
|
||||
// Activate deferred MCP-tool loading for this run only when the VISIBLE
|
||||
// deferred-eligible count meets the resolved threshold; below threshold this
|
||||
// is a no-op and tool advertising stays byte-identical. The registry is
|
||||
// already complete (core + MCP) at this point, so the count is accurate. The
|
||||
// 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)
|
||||
images, err := resolveExecImages(options.imagePaths, workspaceRoot)
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -218,12 +269,16 @@ 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 options.reasoningEffort != "" {
|
||||
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, options.reasoningEffort); notice != "" {
|
||||
if runReasoningEffort != "" {
|
||||
if notice := reasoningEffortNotice(modelRegistry, resolved.Provider.Model, runReasoningEffort); notice != "" {
|
||||
if _, err := fmt.Fprintln(stderr, notice); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
@@ -234,10 +289,63 @@ 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
|
||||
@@ -311,6 +419,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,
|
||||
SessionID: preparedSession.Session.SessionID,
|
||||
CallingSessionID: options.callingSessionID,
|
||||
CallingToolUseID: options.callingToolUseID,
|
||||
@@ -318,7 +427,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
Depth: options.depth,
|
||||
SessionTitle: sessionTitle,
|
||||
Model: resolved.Provider.Model,
|
||||
ReasoningEffort: options.reasoningEffort,
|
||||
ModelSwitcher: modelSwitcher,
|
||||
ReasoningEffort: runReasoningEffort,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
@@ -365,13 +475,23 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
},
|
||||
OnUsage: func(usage agent.Usage) {
|
||||
writer.usage(usage)
|
||||
sessionRecorder.append(sessions.EventUsage, map[string]any{
|
||||
payload := 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
|
||||
}
|
||||
@@ -414,6 +534,46 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
return exitSuccess
|
||||
}
|
||||
|
||||
// deferredEligibleCount returns the number of registered tools that are
|
||||
// deferred-eligible (MCP tools) AND visible to the model for THIS run — i.e. they
|
||||
// pass the same agent.ToolVisible gate (permission-mode advertising + operator
|
||||
// allow/deny filters) that the agent loop's partitionTools applies when it
|
||||
// decides whether deferral activates. Counting the SAME visible-deferred
|
||||
// population here keeps registration and activation in agreement: tool_search is
|
||||
// registered iff the partition will actually go active. Built-ins never implement
|
||||
// the Deferred interface, so they never count.
|
||||
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
|
||||
count := 0
|
||||
for _, tool := range registry.All() {
|
||||
if !tools.IsDeferred(tool) {
|
||||
continue
|
||||
}
|
||||
if !agent.ToolVisible(tool, permissionMode, enabledTools, disabledTools) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// registerToolSearchIfEligible registers the tool_search tool only when deferral
|
||||
// is active for this run: the visible-deferred count (the same population the
|
||||
// agent loop's partition counts) meets the (positive) threshold. Below threshold
|
||||
// or with a zero/negative threshold, tool_search is never registered, so the
|
||||
// agent loop's partition stays inactive and tool advertising is byte-identical to
|
||||
// today. The permissionMode + enabled/disabled filters MUST match the values the
|
||||
// run passes to agent.Run so the registration gate and the activation gate count
|
||||
// the same tools.
|
||||
func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) {
|
||||
if deferThreshold <= 0 {
|
||||
return
|
||||
}
|
||||
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
|
||||
return
|
||||
}
|
||||
registry.Register(tools.NewToolSearchTool(registry))
|
||||
}
|
||||
|
||||
func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig, deps appDeps) (*sandbox.Engine, error) {
|
||||
store, err := deps.newSandboxStore()
|
||||
if err != nil {
|
||||
@@ -757,3 +917,16 @@ 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,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/specialist"
|
||||
)
|
||||
|
||||
func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
@@ -23,6 +24,23 @@ 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 {
|
||||
@@ -112,6 +130,34 @@ 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 {
|
||||
@@ -314,9 +360,31 @@ 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."}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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,3 +1135,745 @@ 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,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -34,7 +35,11 @@ func validateExecToolFilters(options execOptions, registry *tools.Registry) erro
|
||||
if !toolNamePattern.MatchString(name) {
|
||||
return execUsageError{fmt.Sprintf("Invalid tool name %q.", name)}
|
||||
}
|
||||
if _, ok := registry.Get(name); !ok {
|
||||
// tool_search is registered later (only when deferral activates), so it is
|
||||
// not in the registry yet at validation time. Treat it as always-valid so an
|
||||
// operator can list it in --enabled-tools / --disabled-tools without a
|
||||
// spurious "Unknown tool" error.
|
||||
if _, ok := registry.Get(name); !ok && name != tools.ToolSearchToolName {
|
||||
return execUsageError{fmt.Sprintf("Unknown tool: %s", name)}
|
||||
}
|
||||
}
|
||||
@@ -47,9 +52,26 @@ 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,6 +390,8 @@ func streamJSONSideEffect(name string, registry *tools.Registry) string {
|
||||
return "shell"
|
||||
case tools.SideEffectNetwork:
|
||||
return "network"
|
||||
case tools.SideEffectNone:
|
||||
return "none"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
}
|
||||
+51
-18
@@ -21,11 +21,14 @@ 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", 1, "color theme 1-5 (1 Phosphor, 2 Cyan, 3 Sage, 4 Violet, 5 Mono)")
|
||||
variant := fs.Int("variant", 0, "color theme 0-5 (0 ZERO, 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)")
|
||||
@@ -34,16 +37,10 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
}
|
||||
|
||||
if *snapshot {
|
||||
v := *variant - 1
|
||||
v := *variant // theme index directly (0 ZERO, 1 Phosphor, …)
|
||||
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" {
|
||||
@@ -51,19 +48,29 @@ 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: "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: "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: "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: "❯ ",
|
||||
Input: "add a test for the new ToolExecutor",
|
||||
Spin: *frameN,
|
||||
}
|
||||
if *tool != "" {
|
||||
cd.Rows = toolSnapshotRows(*tool)
|
||||
}
|
||||
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
|
||||
@@ -72,9 +79,11 @@ func runZeroline(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
|
||||
}
|
||||
frame = zeroline.RenderChat(cd)
|
||||
} else {
|
||||
frame = zeroline.RenderHome(zeroline.HomeData{
|
||||
frame = zeroline.RenderChat(zeroline.ChatData{
|
||||
Variant: v, Dark: !*light, Width: *width, Height: *height, Header: hdr,
|
||||
Input: "❯ message zero — / commands · @ files · ! bash",
|
||||
Input: "describe a task for zero…",
|
||||
Chips: zeroline.DefaultChips(),
|
||||
ChipIndex: 0,
|
||||
})
|
||||
}
|
||||
if _, err := fmt.Fprintln(stdout, frame); err != nil {
|
||||
@@ -89,3 +98,27 @@ 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"}}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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,12 +8,15 @@ 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"
|
||||
)
|
||||
|
||||
const defaultMaxTurns = 12
|
||||
|
||||
const defaultDeferThreshold = 10
|
||||
|
||||
func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
cfg := FileConfig{
|
||||
MaxTurns: defaultMaxTurns,
|
||||
@@ -48,6 +51,13 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
|
||||
applyOverrides(&cfg, options.Overrides)
|
||||
|
||||
if !cfg.Tools.deferThresholdSet && cfg.Tools.DeferThreshold == 0 {
|
||||
cfg.Tools.DeferThreshold = defaultDeferThreshold
|
||||
}
|
||||
if cfg.Tools.DeferThreshold < 0 {
|
||||
return ResolvedConfig{}, fmt.Errorf("invalid tools.deferThreshold %d: must be >= 0", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
|
||||
if maxAutonomy := strings.TrimSpace(cfg.Sandbox.MaxAutonomy); maxAutonomy != "" {
|
||||
// Fail loud on an invalid ceiling. An unvalidated typo (e.g. "moderate")
|
||||
// would otherwise survive Resolve untouched, reach the sandbox bridge,
|
||||
@@ -57,6 +67,20 @@ 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 {
|
||||
@@ -70,6 +94,8 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
Sandbox: cfg.Sandbox,
|
||||
Notify: cfg.Notify,
|
||||
Tools: cfg.Tools,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -117,6 +143,16 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
@@ -137,6 +173,16 @@ 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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -441,6 +487,16 @@ 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
|
||||
}
|
||||
for _, provider := range overrides.Providers {
|
||||
mergeProvider(cfg, provider)
|
||||
}
|
||||
|
||||
@@ -1284,6 +1284,42 @@ 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()
|
||||
|
||||
@@ -1305,3 +1341,153 @@ func providerByName(t *testing.T, providers []ProviderProfile, name string) Prov
|
||||
t.Fatalf("provider %q not found in %#v", name, providers)
|
||||
return ProviderProfile{}
|
||||
}
|
||||
|
||||
func TestResolveDefaultsDeferThresholdWhenUnset(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}]
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != defaultDeferThreshold {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want %d", resolved.Tools.DeferThreshold, defaultDeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeferThresholdFromFile(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
|
||||
"tools": {"deferThreshold": 4}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != 4 {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeferThresholdZeroDisablesViaFile(t *testing.T) {
|
||||
// 0 is a valid, meaningful value (disabled). It must survive Resolve and NOT
|
||||
// be re-defaulted to 10, so a user can explicitly disable deferral.
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
|
||||
"tools": {"deferThreshold": 0}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != 0 {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want 0 (explicit disable preserved)", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeferThresholdOverrideWins(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
|
||||
"tools": {"deferThreshold": 4}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{Tools: ToolsConfig{DeferThreshold: 20}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != 20 {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want 20 (override wins)", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToolsOverrideDisablesDeferralOverNonZeroBase(t *testing.T) {
|
||||
// A programmatic Override built via ToolsOverride(0) carries the presence flag,
|
||||
// so it must override an explicit non-zero base threshold down to 0 (disabled).
|
||||
// A bare ToolsConfig{DeferThreshold: 0} could not do this — it is indistinguishable
|
||||
// from "unset" — which is exactly the trap ToolsOverride exists to avoid.
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
|
||||
"tools": {"deferThreshold": 4}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{Tools: ToolsOverride(0)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != 0 {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want 0 (ToolsOverride(0) disables deferral)", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToolsOverrideSetsNonZeroOverNonZeroBase(t *testing.T) {
|
||||
// ToolsOverride(7) over an explicit non-zero base must win with the new value.
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
|
||||
"tools": {"deferThreshold": 4}
|
||||
}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
ProjectConfigPath: path,
|
||||
Env: map[string]string{},
|
||||
Overrides: Overrides{Tools: ToolsOverride(7)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != 7 {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want 7 (ToolsOverride(7) wins)", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyOverridesToolsOverrideZeroOverridesNonZero(t *testing.T) {
|
||||
// Unit-level check on applyOverrides directly: ToolsOverride(0) must flip an
|
||||
// explicit non-zero base to 0 (deferThresholdSet honored), while a bare
|
||||
// non-zero ToolsConfig still overrides via the != 0 branch.
|
||||
cfg := FileConfig{Tools: ToolsOverride(4)}
|
||||
applyOverrides(&cfg, Overrides{Tools: ToolsOverride(0)})
|
||||
if cfg.Tools.DeferThreshold != 0 {
|
||||
t.Fatalf("applyOverrides ToolsOverride(0): DeferThreshold = %d, want 0", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
if !cfg.Tools.deferThresholdSet {
|
||||
t.Fatal("applyOverrides ToolsOverride(0): deferThresholdSet = false, want true")
|
||||
}
|
||||
|
||||
cfg = FileConfig{Tools: ToolsOverride(4)}
|
||||
applyOverrides(&cfg, Overrides{Tools: ToolsConfig{DeferThreshold: 5}})
|
||||
if cfg.Tools.DeferThreshold != 5 {
|
||||
t.Fatalf("applyOverrides bare non-zero: DeferThreshold = %d, want 5", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsNegativeDeferThreshold(t *testing.T) {
|
||||
path := writeConfig(t, `{
|
||||
"activeProvider": "p",
|
||||
"providers": [{"name": "p", "provider": "openai", "apiKey": "sk", "model": "m"}],
|
||||
"tools": {"deferThreshold": -1}
|
||||
}`)
|
||||
|
||||
_, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil for negative deferThreshold, want failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "tools.deferThreshold") {
|
||||
t.Fatalf("Resolve() error = %v, want tools.deferThreshold rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,49 @@ 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
|
||||
}
|
||||
|
||||
// ToolsOverride builds a ToolsConfig that explicitly overrides the deferred-tool
|
||||
// threshold (including to 0, which disables deferral). Use this for programmatic
|
||||
// Overrides — a bare ToolsConfig{DeferThreshold: 0} is indistinguishable from
|
||||
// "unset" and will not override.
|
||||
func ToolsOverride(deferThreshold int) ToolsConfig {
|
||||
return ToolsConfig{DeferThreshold: deferThreshold, deferThresholdSet: true}
|
||||
}
|
||||
|
||||
func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawTools struct {
|
||||
DeferThreshold *int `json:"deferThreshold"`
|
||||
}
|
||||
var raw rawTools
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DeferThreshold = 0
|
||||
cfg.deferThresholdSet = false
|
||||
if raw.DeferThreshold != nil {
|
||||
cfg.DeferThreshold = *raw.DeferThreshold
|
||||
cfg.deferThresholdSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type ResolveOptions struct {
|
||||
@@ -81,6 +118,8 @@ type Overrides struct {
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
@@ -90,6 +129,8 @@ type ResolvedConfig struct {
|
||||
MaxTurns int
|
||||
MCP MCPConfig
|
||||
Sandbox SandboxConfig
|
||||
Notify NotifyConfig
|
||||
Tools ToolsConfig
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
@@ -114,6 +155,8 @@ 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"`
|
||||
}
|
||||
@@ -127,6 +170,8 @@ 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{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolsConfigJSONRoundTrip(t *testing.T) {
|
||||
var cfg FileConfig
|
||||
if err := json.Unmarshal([]byte(`{"tools":{"deferThreshold":25}}`), &cfg); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if cfg.Tools.DeferThreshold != 25 {
|
||||
t.Fatalf("Tools.DeferThreshold = %d, want 25", cfg.Tools.DeferThreshold)
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(ToolsConfig{DeferThreshold: 7})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if string(encoded) != `{"deferThreshold":7}` {
|
||||
t.Fatalf("Marshal() = %s, want {\"deferThreshold\":7}", encoded)
|
||||
}
|
||||
|
||||
// omitempty: a zero value must not emit the field.
|
||||
emptyEncoded, err := json.Marshal(ToolsConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(empty) error = %v", err)
|
||||
}
|
||||
if string(emptyEncoded) != `{}` {
|
||||
t.Fatalf("Marshal(empty) = %s, want {}", emptyEncoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsConfigPresentOnOverridesAndResolved(t *testing.T) {
|
||||
// Compile-time guard that Overrides and ResolvedConfig carry the field too.
|
||||
overrides := Overrides{Tools: ToolsConfig{DeferThreshold: 3}}
|
||||
resolved := ResolvedConfig{Tools: ToolsConfig{DeferThreshold: 4}}
|
||||
if overrides.Tools.DeferThreshold != 3 {
|
||||
t.Fatalf("Overrides.Tools.DeferThreshold = %d, want 3", overrides.Tools.DeferThreshold)
|
||||
}
|
||||
if resolved.Tools.DeferThreshold != 4 {
|
||||
t.Fatalf("ResolvedConfig.Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,22 @@ func (tool registryTool) Safety() tools.Safety {
|
||||
return tool.safety
|
||||
}
|
||||
|
||||
// Deferred marks every MCP tool as deferred-eligible: when many MCP tools are
|
||||
// registered the agent loop may withhold their full schema and advertise them
|
||||
// via tool_search. Built-in tools do not implement this interface and stay
|
||||
// eager.
|
||||
func (tool registryTool) Deferred() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MCPServerName reports the tool's originating MCP server name so the deferred-
|
||||
// tools reminder labels it correctly, even when the sanitized server token in the
|
||||
// synthesized tool name contains an underscore (which the name-only parser would
|
||||
// truncate). It returns the true configured server name, not the sanitized token.
|
||||
func (tool registryTool) MCPServerName() string {
|
||||
return tool.server.Name
|
||||
}
|
||||
|
||||
func (tool registryTool) Run(ctx context.Context, args map[string]any) tools.Result {
|
||||
result, err := tool.client.CallTool(ctx, tool.remote.Name, args)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -171,11 +172,75 @@ func TestRegisterToolsPreservesPriorRegistryStateOnFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryToolIsDeferredEligible(t *testing.T) {
|
||||
client := &fakeToolClient{}
|
||||
tool := newRegistryTool(
|
||||
Server{Name: "docs", Type: "stdio"},
|
||||
RemoteTool{Name: "lookup", Description: "Lookup documentation"},
|
||||
client,
|
||||
RegisterOptions{},
|
||||
)
|
||||
|
||||
// Sanity: the wrapper synthesizes the expected sanitized name so the test
|
||||
// exercises the real production path, not a hand-built struct.
|
||||
if tool.Name() != "mcp_docs_lookup" {
|
||||
t.Fatalf("registryTool.Name() = %q, want mcp_docs_lookup", tool.Name())
|
||||
}
|
||||
|
||||
if !tool.Deferred() {
|
||||
t.Fatal("registryTool.Deferred() = false, want true (all MCP tools are deferred-eligible)")
|
||||
}
|
||||
|
||||
// The exported helper in the tools package must agree via the optional
|
||||
// interface, since the agent loop partitions tools through tools.IsDeferred.
|
||||
if !tools.IsDeferred(tool) {
|
||||
t.Fatal("tools.IsDeferred(registryTool) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryToolReportsMCPServerName verifies the registryTool reports its true
|
||||
// configured server name (not the sanitized tool-name token) so the deferred-tools
|
||||
// reminder labels a multi-token server correctly via tools.DeferredLine.
|
||||
func TestRegistryToolReportsMCPServerName(t *testing.T) {
|
||||
client := &fakeToolClient{}
|
||||
// A server name that sanitizes to a token containing an underscore ("git_hub"):
|
||||
// the name-only parser would truncate the label to "git".
|
||||
tool := newRegistryTool(
|
||||
Server{Name: "git hub", Type: "stdio"},
|
||||
RemoteTool{Name: "create_issue", Description: "Create a GitHub issue."},
|
||||
client,
|
||||
RegisterOptions{},
|
||||
)
|
||||
|
||||
if tool.MCPServerName() != "git hub" {
|
||||
t.Fatalf("MCPServerName() = %q, want %q", tool.MCPServerName(), "git hub")
|
||||
}
|
||||
|
||||
// DeferredLine must prefer the reported server name over the name-derived token.
|
||||
line := tools.DeferredLine(tool)
|
||||
if !strings.Contains(line, "server: git hub") {
|
||||
t.Fatalf("DeferredLine = %q, want it to label server as %q via MCPServerName()", line, "git hub")
|
||||
}
|
||||
// The truncated token-only label ("git") must NOT be the server segment.
|
||||
if strings.Contains(line, "server: git |") {
|
||||
t.Fatalf("DeferredLine = %q, mislabeled multi-token server with the truncated token", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryToolNameRoundTripsToServerToken pins the synthesized name format
|
||||
// for a single-token server so the fallback label parser (mcpServerFromToolName,
|
||||
// exercised in the tools package) recovers the right server token.
|
||||
func TestRegistryToolNameRoundTripsToServerToken(t *testing.T) {
|
||||
if name := registryToolName("docs", "lookup"); name != "mcp_docs_lookup" {
|
||||
t.Fatalf("registryToolName(docs, lookup) = %q, want mcp_docs_lookup", name)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePreexistingTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t *fakePreexistingTool) Name() string { return t.name }
|
||||
func (t *fakePreexistingTool) Name() string { return t.name }
|
||||
func (t *fakePreexistingTool) Description() string { return "preexisting tool" }
|
||||
func (t *fakePreexistingTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (t *fakePreexistingTool) Safety() tools.Safety { return tools.Safety{} }
|
||||
|
||||
@@ -51,10 +51,12 @@ 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,
|
||||
@@ -63,6 +65,7 @@ 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,
|
||||
@@ -70,6 +73,19 @@ 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{
|
||||
@@ -100,6 +116,9 @@ func decorateModelDepth(entries []ModelEntry) {
|
||||
if extra.deprecation != nil {
|
||||
entries[index].Deprecation = extra.deprecation.Clone()
|
||||
}
|
||||
if extra.upgradeTarget != "" {
|
||||
entries[index].UpgradeTargetID = extra.upgradeTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,49 @@ 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,7 +96,11 @@ type ModelEntry struct {
|
||||
MatchPatterns []string
|
||||
// Deprecation, when set, redirects this model to a replacement.
|
||||
Deprecation *DeprecationRule
|
||||
Description string
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeprecationRule describes how a deprecated model is phased out and what to use
|
||||
@@ -342,6 +346,18 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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:
|
||||
case SideEffectRead, SideEffectWrite, SideEffectShell, SideEffectNetwork, SideEffectOutOfWorkspace, SideEffectNone:
|
||||
return normalized
|
||||
default:
|
||||
return SideEffectOutOfWorkspace
|
||||
|
||||
@@ -30,3 +30,13 @@ 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,6 +83,9 @@ 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,3 +219,13 @@ 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,6 +22,10 @@ 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 (
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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,6 +500,70 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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,6 +125,48 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShortenMax = 200
|
||||
maxSchemaHintParams = 4
|
||||
maxSchemaHintLen = 360
|
||||
)
|
||||
|
||||
var (
|
||||
headingPrefix = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
|
||||
genericHeading = regexp.MustCompile(`(?i)^(overview|description|summary)$`)
|
||||
collapseWhitespace = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// normalizeDescriptionLine trims a line and unwraps a leading markdown heading.
|
||||
func normalizeDescriptionLine(line string) string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if m := headingPrefix.FindStringSubmatch(trimmed); m != nil {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func isGenericDescriptionHeading(line string) bool {
|
||||
return genericHeading.MatchString(line)
|
||||
}
|
||||
|
||||
// truncateDescription clips desc to at most max runes, preferring a word
|
||||
// boundary and appending a single-rune ellipsis when it had to cut.
|
||||
func truncateDescription(desc string, max int) string {
|
||||
runes := []rune(desc)
|
||||
if max <= 0 || len(runes) <= max {
|
||||
return desc
|
||||
}
|
||||
cut := string(runes[:max-1])
|
||||
if idx := strings.LastIndexByte(cut, ' '); idx > 0 {
|
||||
cut = cut[:idx]
|
||||
}
|
||||
return strings.TrimRight(cut, " ") + "…"
|
||||
}
|
||||
|
||||
// shortenDescription reduces desc to a single meaningful line, collapses
|
||||
// whitespace, and truncates to at most max runes with an ellipsis.
|
||||
func shortenDescription(desc string, max int) string {
|
||||
if desc == "" {
|
||||
return ""
|
||||
}
|
||||
if max <= 0 {
|
||||
max = defaultShortenMax
|
||||
}
|
||||
var lines []string
|
||||
for _, raw := range strings.Split(desc, "\n") {
|
||||
if line := normalizeDescriptionLine(raw); line != "" {
|
||||
lines = append(lines, collapseWhitespace.ReplaceAllString(line, " "))
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
meaningful := lines[0]
|
||||
if isGenericDescriptionHeading(meaningful) && len(lines) > 1 {
|
||||
meaningful = meaningful + " — " + lines[1]
|
||||
}
|
||||
return truncateDescription(meaningful, max)
|
||||
}
|
||||
|
||||
// formatInputSchemaHint renders a one-line summary of a tool's input schema,
|
||||
// e.g. "inputs (* required): a (string)*, b (number); +N more". Property names
|
||||
// are sorted for deterministic output (Schema.Properties is a map). Returns
|
||||
// "(none)" when the schema declares no properties. At most maxSchemaHintParams
|
||||
// params are shown; the rest are summarized as "; +N more".
|
||||
func formatInputSchemaHint(schema Schema) string {
|
||||
if len(schema.Properties) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(schema.Properties))
|
||||
for name := range schema.Properties {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
required := make(map[string]bool, len(schema.Required))
|
||||
for _, name := range schema.Required {
|
||||
required[name] = true
|
||||
}
|
||||
|
||||
shown := names
|
||||
if len(shown) > maxSchemaHintParams {
|
||||
shown = shown[:maxSchemaHintParams]
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(shown))
|
||||
for _, name := range shown {
|
||||
prop := schema.Properties[name]
|
||||
marker := ""
|
||||
if required[name] {
|
||||
marker = "*"
|
||||
}
|
||||
typePart := ""
|
||||
if t := strings.TrimSpace(prop.Type); t != "" {
|
||||
typePart = " (" + t + ")"
|
||||
}
|
||||
parts = append(parts, name+typePart+marker)
|
||||
}
|
||||
|
||||
more := ""
|
||||
if len(names) > maxSchemaHintParams {
|
||||
more = fmt.Sprintf("; +%d more", len(names)-maxSchemaHintParams)
|
||||
}
|
||||
|
||||
hint := "inputs (* required): " + strings.Join(parts, ", ") + more
|
||||
return shortenDescription(hint, maxSchemaHintLen)
|
||||
}
|
||||
|
||||
// mcpToolNamePrefix mirrors the prefix used by mcp.registryToolName.
|
||||
const mcpToolNamePrefix = "mcp_"
|
||||
|
||||
// mcpServerFromToolName extracts the server token from a synthesized MCP tool
|
||||
// name produced by mcp.registryToolName ("mcp_<server>_<tool>"). It returns ""
|
||||
// for non-MCP names and for names that lack both a server and a tool segment.
|
||||
func mcpServerFromToolName(name string) string {
|
||||
rest, ok := strings.CutPrefix(name, mcpToolNamePrefix)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
sep := strings.IndexByte(rest, '_')
|
||||
if sep <= 0 || sep == len(rest)-1 {
|
||||
// No server token, or nothing after the server token (no tool part).
|
||||
return ""
|
||||
}
|
||||
return rest[:sep]
|
||||
}
|
||||
|
||||
// formatDeferredToolLine renders a single compact advertisement line for a
|
||||
// deferred tool: "name: <short-desc> | server: <X> | <input-hint>". The
|
||||
// "server: <X>" segment is omitted when server is empty (non-MCP tools).
|
||||
func formatDeferredToolLine(name, description, server string, schema Schema) string {
|
||||
desc := shortenDescription(description, defaultShortenMax)
|
||||
if desc == "" {
|
||||
desc = "No description provided"
|
||||
}
|
||||
parts := []string{name + ": " + desc}
|
||||
if server != "" {
|
||||
parts = append(parts, "server: "+server)
|
||||
}
|
||||
parts = append(parts, formatInputSchemaHint(schema))
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
// mcpServerNamed is an optional interface a deferred MCP tool implements to
|
||||
// report its true (un-sanitized-token) server name for the reminder label. When
|
||||
// a tool provides it, DeferredLine prefers it over the name-derived token, which
|
||||
// would mislabel a server whose sanitized name itself contains an underscore
|
||||
// (e.g. "git_hub" → "git"). It affects the cosmetic reminder label only; tool
|
||||
// resolution never depends on this.
|
||||
type mcpServerNamed interface {
|
||||
MCPServerName() string
|
||||
}
|
||||
|
||||
// DeferredLine renders the compact advertisement line for a single deferred
|
||||
// tool, deriving the MCP server label from the tool's reported server name when
|
||||
// available, falling back to the token parsed from the tool's name. It is the
|
||||
// exported entry point the agent loop uses to build each line of the
|
||||
// deferred-tools reminder, so callers in other packages never touch the
|
||||
// unexported formatters.
|
||||
func DeferredLine(t Tool) string {
|
||||
server := mcpServerFromToolName(t.Name())
|
||||
if named, ok := t.(mcpServerNamed); ok {
|
||||
if reported := strings.TrimSpace(named.MCPServerName()); reported != "" {
|
||||
server = reported
|
||||
}
|
||||
}
|
||||
return formatDeferredToolLine(t.Name(), t.Description(), server, t.Parameters())
|
||||
}
|
||||
|
||||
const (
|
||||
systemReminderStart = "<system-reminder>"
|
||||
systemReminderEnd = "</system-reminder>"
|
||||
)
|
||||
|
||||
// BuildDeferredReminder wraps the given deferred-tool advertisement lines in a
|
||||
// <system-reminder> block instructing the model to load a tool's full schema
|
||||
// via tool_search (using a `select:Name1,Name2` exact query or keywords)
|
||||
// before calling it. Returns "" when there are no lines.
|
||||
func BuildDeferredReminder(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(systemReminderStart)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("The tools listed below are available in this environment, but their full schemas are omitted from the current tool list to save context.\n")
|
||||
b.WriteString(`Call tool_search with query "select:Name1,Name2" (the exact names before each colon) or with keywords to load a tool's full schema before calling it. Calling an omitted tool directly will fail with an input validation error.` + "\n")
|
||||
b.WriteString("Do not call tool_search for tools already present in the current tool list, and do not guess or invent tool names.\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString("Deferred tools:\n")
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(systemReminderEnd)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShortenDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
desc string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{name: "empty", desc: "", max: 200, want: ""},
|
||||
{name: "single short line", desc: "Reads a file from disk.", max: 200, want: "Reads a file from disk."},
|
||||
{
|
||||
name: "takes first non-empty line",
|
||||
desc: "\n\nReads a file.\nSecond line ignored.",
|
||||
max: 200,
|
||||
want: "Reads a file.",
|
||||
},
|
||||
{
|
||||
name: "strips markdown heading",
|
||||
desc: "## Read tool\nbody",
|
||||
max: 200,
|
||||
want: "Read tool",
|
||||
},
|
||||
{
|
||||
name: "generic heading joins next line",
|
||||
desc: "Overview\nFetches a URL over HTTP.",
|
||||
max: 200,
|
||||
want: "Overview — Fetches a URL over HTTP.",
|
||||
},
|
||||
{
|
||||
name: "collapses internal whitespace",
|
||||
desc: "Runs a\tshell command",
|
||||
max: 200,
|
||||
want: "Runs a shell command",
|
||||
},
|
||||
{
|
||||
name: "truncates on word boundary with ellipsis",
|
||||
desc: "alpha beta gamma delta epsilon zeta",
|
||||
max: 20,
|
||||
want: "alpha beta gamma…",
|
||||
},
|
||||
{
|
||||
name: "truncates mid-word when no boundary",
|
||||
desc: "supercalifragilistic",
|
||||
max: 10,
|
||||
want: "supercali…",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := shortenDescription(tc.desc, tc.max)
|
||||
if got != tc.want {
|
||||
t.Fatalf("shortenDescription(%q, %d) = %q, want %q", tc.desc, tc.max, got, tc.want)
|
||||
}
|
||||
if len([]rune(got)) > tc.max && tc.max > 0 {
|
||||
t.Fatalf("result %q exceeds max %d runes", got, tc.max)
|
||||
}
|
||||
if strings.Contains(got, "\n") {
|
||||
t.Fatalf("result %q contains a newline", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatInputSchemaHint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schema Schema
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no properties",
|
||||
schema: Schema{Type: "object"},
|
||||
want: "(none)",
|
||||
},
|
||||
{
|
||||
name: "required marked and sorted",
|
||||
schema: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"a": {Type: "string"},
|
||||
"b": {Type: "number"},
|
||||
},
|
||||
Required: []string{"a"},
|
||||
},
|
||||
want: "inputs (* required): a (string)*, b (number)",
|
||||
},
|
||||
{
|
||||
name: "untyped property omits type parens",
|
||||
schema: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"q": {},
|
||||
},
|
||||
Required: []string{"q"},
|
||||
},
|
||||
want: "inputs (* required): q*",
|
||||
},
|
||||
{
|
||||
name: "caps at four and reports remainder",
|
||||
schema: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"a": {Type: "string"},
|
||||
"b": {Type: "string"},
|
||||
"c": {Type: "string"},
|
||||
"d": {Type: "string"},
|
||||
"e": {Type: "string"},
|
||||
"f": {Type: "string"},
|
||||
},
|
||||
Required: []string{"a"},
|
||||
},
|
||||
want: "inputs (* required): a (string)*, b (string), c (string), d (string); +2 more",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := formatInputSchemaHint(tc.schema); got != tc.want {
|
||||
t.Fatalf("formatInputSchemaHint() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPServerFromToolName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolName string
|
||||
want string
|
||||
}{
|
||||
{name: "standard mcp name", toolName: "mcp_github_create_issue", want: "github"},
|
||||
{name: "single tool segment", toolName: "mcp_weather_forecast", want: "weather"},
|
||||
{name: "server with no tool part", toolName: "mcp_github_tool", want: "github"},
|
||||
{name: "non-mcp builtin", toolName: "read_file", want: ""},
|
||||
{name: "tool_search itself", toolName: "tool_search", want: ""},
|
||||
{name: "prefix only no server", toolName: "mcp_", want: ""},
|
||||
{name: "prefix and server but no tool", toolName: "mcp_github", want: ""},
|
||||
{name: "empty", toolName: "", want: ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := mcpServerFromToolName(tc.toolName); got != tc.want {
|
||||
t.Fatalf("mcpServerFromToolName(%q) = %q, want %q", tc.toolName, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDeferredToolLine(t *testing.T) {
|
||||
schema := Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"url": {Type: "string"},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
}
|
||||
|
||||
t.Run("with server", func(t *testing.T) {
|
||||
got := formatDeferredToolLine("mcp_github_fetch", "Fetches a URL.", "github", schema)
|
||||
want := "mcp_github_fetch: Fetches a URL. | server: github | inputs (* required): url (string)*"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omits server when empty", func(t *testing.T) {
|
||||
got := formatDeferredToolLine("read_file", "Reads a file.", "", schema)
|
||||
want := "read_file: Reads a file. | inputs (* required): url (string)*"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, "server:") {
|
||||
t.Fatalf("server part leaked into %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty schema shows none", func(t *testing.T) {
|
||||
got := formatDeferredToolLine("ping", "Pings.", "", Schema{Type: "object"})
|
||||
want := "ping: Pings. | (none)"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("blank description gets placeholder", func(t *testing.T) {
|
||||
got := formatDeferredToolLine("noop", "", "", Schema{Type: "object"})
|
||||
want := "noop: No description provided | (none)"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("long description truncated", func(t *testing.T) {
|
||||
long := strings.Repeat("word ", 80) // 400 chars
|
||||
got := formatDeferredToolLine("big", long, "", Schema{Type: "object"})
|
||||
if !strings.Contains(got, "…") {
|
||||
t.Fatalf("expected truncation ellipsis in %q", got)
|
||||
}
|
||||
descPart := strings.SplitN(strings.TrimPrefix(got, "big: "), " | ", 2)[0]
|
||||
if len([]rune(descPart)) > defaultShortenMax {
|
||||
t.Fatalf("description part %d runes exceeds %d", len([]rune(descPart)), defaultShortenMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDeferredReminder(t *testing.T) {
|
||||
t.Run("no lines returns empty", func(t *testing.T) {
|
||||
if got := BuildDeferredReminder(nil); got != "" {
|
||||
t.Fatalf("BuildDeferredReminder(nil) = %q, want empty", got)
|
||||
}
|
||||
if got := BuildDeferredReminder([]string{}); got != "" {
|
||||
t.Fatalf("BuildDeferredReminder([]) = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps lines in system-reminder", func(t *testing.T) {
|
||||
lines := []string{
|
||||
"mcp_github_fetch: Fetches a URL. | server: github | inputs (* required): url (string)*",
|
||||
"mcp_weather_now: Current weather. | server: weather | (none)",
|
||||
}
|
||||
got := BuildDeferredReminder(lines)
|
||||
|
||||
if !strings.HasPrefix(got, "<system-reminder>") {
|
||||
t.Fatalf("missing opening tag: %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "</system-reminder>") {
|
||||
t.Fatalf("missing closing tag: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "tool_search") {
|
||||
t.Fatalf("reminder must name the tool_search tool: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `select:`) {
|
||||
t.Fatalf("reminder must explain select: query form: %q", got)
|
||||
}
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(got, line) {
|
||||
t.Fatalf("reminder missing line %q in %q", line, got)
|
||||
}
|
||||
}
|
||||
// Lines are newline-joined, not concatenated.
|
||||
if !strings.Contains(got, lines[0]+"\n"+lines[1]) {
|
||||
t.Fatalf("lines not newline-joined in %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeferredLineUsesToolFieldsAndServer(t *testing.T) {
|
||||
tool := fakeDeferredTool{
|
||||
baseTool: baseTool{
|
||||
name: "mcp_docs_lookup",
|
||||
description: "Look up documentation for a symbol.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{"symbol": {Type: "string"}},
|
||||
Required: []string{"symbol"},
|
||||
},
|
||||
},
|
||||
deferred: true,
|
||||
}
|
||||
got := DeferredLine(tool)
|
||||
for _, want := range []string{"mcp_docs_lookup: Look up documentation", "server: docs", "inputs (* required): symbol (string)*"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("DeferredLine = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serverNamedTool implements the optional mcpServerNamed interface so DeferredLine
|
||||
// can prefer the reported server name over the name-derived token.
|
||||
type serverNamedTool struct {
|
||||
baseTool
|
||||
server string
|
||||
}
|
||||
|
||||
func (t serverNamedTool) Run(context.Context, map[string]any) Result { return okResult("ok") }
|
||||
func (t serverNamedTool) Deferred() bool { return true }
|
||||
func (t serverNamedTool) MCPServerName() string { return t.server }
|
||||
|
||||
func TestDeferredLinePrefersMCPServerName(t *testing.T) {
|
||||
// The tool name sanitizes to a server token containing an underscore
|
||||
// ("git_hub"): mcpServerFromToolName would truncate the label to "git", so the
|
||||
// reported server name must win.
|
||||
tool := serverNamedTool{
|
||||
baseTool: baseTool{
|
||||
name: "mcp_git_hub_create_issue",
|
||||
description: "Create a GitHub issue.",
|
||||
parameters: Schema{Type: "object"},
|
||||
},
|
||||
server: "git hub",
|
||||
}
|
||||
got := DeferredLine(tool)
|
||||
if !strings.Contains(got, "server: git hub") {
|
||||
t.Fatalf("DeferredLine = %q, want server label from MCPServerName() (%q)", got, "git hub")
|
||||
}
|
||||
if strings.Contains(got, "server: git |") {
|
||||
t.Fatalf("DeferredLine = %q, used truncated name-derived token instead of MCPServerName()", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A blank/whitespace MCPServerName() must fall back to the name-derived token so a
|
||||
// tool that reports nothing useful is still labeled from its synthesized name.
|
||||
func TestDeferredLineFallsBackWhenMCPServerNameBlank(t *testing.T) {
|
||||
tool := serverNamedTool{
|
||||
baseTool: baseTool{
|
||||
name: "mcp_docs_lookup",
|
||||
description: "Look up docs.",
|
||||
parameters: Schema{Type: "object"},
|
||||
},
|
||||
server: " ",
|
||||
}
|
||||
got := DeferredLine(tool)
|
||||
if !strings.Contains(got, "server: docs") {
|
||||
t.Fatalf("DeferredLine = %q, want fallback server token %q", got, "docs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPServerFromToolNameRoundTrip pins the fallback parser against the name
|
||||
// format produced for a single-token MCP server ("mcp_<server>_<tool>"), the same
|
||||
// format mcp.registryToolName synthesizes.
|
||||
func TestMCPServerFromToolNameRoundTrip(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"mcp_docs_lookup": "docs",
|
||||
"mcp_weather_forecast": "weather",
|
||||
"mcp_github_create_issue": "github",
|
||||
}
|
||||
for name, wantServer := range cases {
|
||||
if got := mcpServerFromToolName(name); got != wantServer {
|
||||
t.Fatalf("mcpServerFromToolName(%q) = %q, want %q", name, got, wantServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,10 @@ 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)
|
||||
result := okResult(summary)
|
||||
// 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.ChangedFiles = []string{relativePath}
|
||||
result.Display = Display{Summary: fmt.Sprintf("Edited %s", relativePath), Kind: "diff"}
|
||||
result.Display = Display{Summary: summary, Kind: "diff"}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,13 @@ type RunOptions struct {
|
||||
ReasoningEffort string
|
||||
Depth int
|
||||
Cwd string
|
||||
// EnabledTools / DisabledTools carry the run's operator tool filters so a
|
||||
// filter-aware tool (tool_search) never discloses or loads an operator-hidden
|
||||
// tool. They use the same allow/deny semantics as the agent's filter gate:
|
||||
// denied if listed in DisabledTools; if EnabledTools is non-empty, allowed
|
||||
// only when listed in it.
|
||||
EnabledTools []string
|
||||
DisabledTools []string
|
||||
}
|
||||
|
||||
type sandboxAwareTool interface {
|
||||
@@ -33,10 +40,25 @@ type optionsAwareTool interface {
|
||||
RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result
|
||||
}
|
||||
|
||||
// deferredTool is an optional interface a tool implements to mark itself
|
||||
// deferred-eligible: when many such tools are registered, the agent loop may
|
||||
// withhold their full schema and advertise them via tool_search instead.
|
||||
type deferredTool interface {
|
||||
Deferred() bool
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{tools: make(map[string]Tool)}
|
||||
}
|
||||
|
||||
// IsDeferred reports whether a tool opts into deferred loading. A tool is
|
||||
// deferred-eligible only if it implements deferredTool and its Deferred()
|
||||
// method returns true; tools that do not implement the interface are eager.
|
||||
func IsDeferred(t Tool) bool {
|
||||
d, ok := t.(deferredTool)
|
||||
return ok && d.Deferred()
|
||||
}
|
||||
|
||||
func (registry *Registry) Register(tool Tool) {
|
||||
registry.tools[tool.Name()] = tool
|
||||
}
|
||||
|
||||
@@ -329,3 +329,47 @@ func TestRunWithOptionsLeavesCleanOutputUnchanged(t *testing.T) {
|
||||
t.Fatalf("clean output altered: redacted=%v output=%q", res.Redacted, res.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeDeferredTool implements the optional Deferred() interface and reports
|
||||
// itself as deferred-eligible; fakePlainTool does not implement it.
|
||||
type fakeDeferredTool struct {
|
||||
baseTool
|
||||
deferred bool
|
||||
}
|
||||
|
||||
func (tool fakeDeferredTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult("ok")
|
||||
}
|
||||
|
||||
func (tool fakeDeferredTool) Deferred() bool { return tool.deferred }
|
||||
|
||||
type fakePlainTool struct {
|
||||
baseTool
|
||||
}
|
||||
|
||||
func (tool fakePlainTool) Run(context.Context, map[string]any) Result {
|
||||
return okResult("ok")
|
||||
}
|
||||
|
||||
func TestIsDeferredReportsOptionalInterface(t *testing.T) {
|
||||
eligible := fakeDeferredTool{
|
||||
baseTool: baseTool{name: "eligible", description: "deferred-eligible tool"},
|
||||
deferred: true,
|
||||
}
|
||||
if !IsDeferred(eligible) {
|
||||
t.Fatal("IsDeferred(eligible) = false, want true for a tool whose Deferred() returns true")
|
||||
}
|
||||
|
||||
notEligible := fakeDeferredTool{
|
||||
baseTool: baseTool{name: "not_eligible", description: "implements interface but opts out"},
|
||||
deferred: false,
|
||||
}
|
||||
if IsDeferred(notEligible) {
|
||||
t.Fatal("IsDeferred(notEligible) = true, want false when Deferred() returns false")
|
||||
}
|
||||
|
||||
plain := fakePlainTool{baseTool: baseTool{name: "plain", description: "no deferred interface"}}
|
||||
if IsDeferred(plain) {
|
||||
t.Fatal("IsDeferred(plain) = true, want false for a tool that does not implement deferredTool")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToolSearchToolName is the canonical registry name of the deferred-tool loader.
|
||||
// It is exported so the agent loop, CLI filter validation, and the partition can
|
||||
// reference the loader without re-stating the literal (keeping all the gates that
|
||||
// must treat it specially in agreement).
|
||||
const ToolSearchToolName = "tool_search"
|
||||
|
||||
// toolSearchMaxKeywordMatches caps how many deferred tools a bare-keyword query
|
||||
// loads in one call, keeping the returned schemas bounded.
|
||||
const toolSearchMaxKeywordMatches = 10
|
||||
|
||||
// toolSearchTool lets the model pull a deferred tool's full schema on demand.
|
||||
// It holds the live registry (like escalate_model holds the model registry) so
|
||||
// it can resolve names against the currently registered deferred-eligible tools.
|
||||
type toolSearchTool struct {
|
||||
baseTool
|
||||
registry *Registry
|
||||
}
|
||||
|
||||
// NewToolSearchTool builds the tool_search tool over the given registry. The
|
||||
// tool is informational/no-side-effect and is advertised even in auto mode so
|
||||
// the model can always discover withheld tools.
|
||||
func NewToolSearchTool(registry *Registry) Tool {
|
||||
return toolSearchTool{
|
||||
baseTool: baseTool{
|
||||
name: ToolSearchToolName,
|
||||
description: "Search for and load deferred tools by exact name or keyword. Use query \"select:Name1,Name2\" to load specific tools, or keywords to find matching tools. Loading a tool returns its full schema so you can call it on the next turn.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"query": {
|
||||
Type: "string",
|
||||
Description: "Either \"select:Name1,Name2\" for exact tool names, or space-separated keywords to match tool names and descriptions.",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: Safety{
|
||||
SideEffect: SideEffectNone,
|
||||
Permission: PermissionAllow,
|
||||
Reason: "Lists and loads already-registered tool schemas; performs no side effects.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
},
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Run satisfies the Tool interface; actual dispatch goes through RunWithOptions.
|
||||
func (tool toolSearchTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
}
|
||||
|
||||
func (tool toolSearchTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
|
||||
query, err := stringArg(args, "query", "", true)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for tool_search: " + err.Error())
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
|
||||
// Honor the run's operator filters so an operator-hidden deferred tool is
|
||||
// invisible to tool_search: it never resolves via select:, never ranks for a
|
||||
// keyword query, and is omitted from the no-match listing.
|
||||
deferred := tool.visibleDeferredTools(options.EnabledTools, options.DisabledTools)
|
||||
|
||||
var matches []Tool
|
||||
if rest, ok := strings.CutPrefix(query, "select:"); ok {
|
||||
matches = tool.resolveExact(rest, deferred)
|
||||
} else {
|
||||
matches = tool.rankByKeyword(query, deferred)
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return okResult(tool.noMatchMessage(query, deferred))
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
names = append(names, match.Name())
|
||||
}
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: renderLoadedTools(matches),
|
||||
Meta: map[string]string{"load_tools": strings.Join(names, ",")},
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// tool (the pre-filter behavior). The allow/deny semantics mirror the agent's
|
||||
// ToolAllowedByFilters: denied if in disabled; if enabled is non-empty, the tool
|
||||
// must be listed in it.
|
||||
func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []string) []Tool {
|
||||
var deferred []Tool
|
||||
if tool.registry != nil {
|
||||
for _, candidate := range tool.registry.All() {
|
||||
if !IsDeferred(candidate) {
|
||||
continue
|
||||
}
|
||||
if !toolAllowedByFilters(candidate.Name(), enabled, disabled) {
|
||||
continue
|
||||
}
|
||||
deferred = append(deferred, candidate)
|
||||
}
|
||||
}
|
||||
sort.Slice(deferred, func(left, right int) bool {
|
||||
return deferred[left].Name() < deferred[right].Name()
|
||||
})
|
||||
return deferred
|
||||
}
|
||||
|
||||
// toolAllowedByFilters mirrors agent.ToolAllowedByFilters (kept here to avoid an
|
||||
// import cycle: the agent package imports tools, not the other way around). A
|
||||
// name is denied when it appears in disabled; when enabled is non-empty, the
|
||||
// name must appear in it to be allowed.
|
||||
func toolAllowedByFilters(name string, enabled []string, disabled []string) bool {
|
||||
if len(enabled) > 0 && !containsName(enabled, name) {
|
||||
return false
|
||||
}
|
||||
return !containsName(disabled, name)
|
||||
}
|
||||
|
||||
func containsName(names []string, name string) bool {
|
||||
for _, candidate := range names {
|
||||
if candidate == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveExact maps a comma-separated name list (the part after "select:") to
|
||||
// the matching deferred tools, preserving the model's order and skipping blanks
|
||||
// and unknown names.
|
||||
func (tool toolSearchTool) resolveExact(list string, deferred []Tool) []Tool {
|
||||
byName := make(map[string]Tool, len(deferred))
|
||||
for _, candidate := range deferred {
|
||||
byName[candidate.Name()] = candidate
|
||||
}
|
||||
var matches []Tool
|
||||
seen := make(map[string]bool)
|
||||
for _, raw := range strings.Split(list, ",") {
|
||||
name := strings.TrimSpace(raw)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
if candidate, ok := byName[name]; ok {
|
||||
seen[name] = true
|
||||
matches = append(matches, candidate)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// rankByKeyword scores deferred tools by case-insensitive substring match on the
|
||||
// name (weighted higher) then the description, and returns the top matches.
|
||||
func (tool toolSearchTool) rankByKeyword(query string, deferred []Tool) []Tool {
|
||||
keywords := strings.Fields(strings.ToLower(query))
|
||||
if len(keywords) == 0 {
|
||||
return nil
|
||||
}
|
||||
type scored struct {
|
||||
tool Tool
|
||||
score int
|
||||
order int
|
||||
}
|
||||
var ranked []scored
|
||||
for index, candidate := range deferred {
|
||||
name := strings.ToLower(candidate.Name())
|
||||
desc := strings.ToLower(candidate.Description())
|
||||
score := 0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(name, keyword) {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(desc, keyword) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
ranked = append(ranked, scored{tool: candidate, score: score, order: index})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ranked, func(left, right int) bool {
|
||||
if ranked[left].score != ranked[right].score {
|
||||
return ranked[left].score > ranked[right].score
|
||||
}
|
||||
return ranked[left].order < ranked[right].order
|
||||
})
|
||||
if len(ranked) > toolSearchMaxKeywordMatches {
|
||||
ranked = ranked[:toolSearchMaxKeywordMatches]
|
||||
}
|
||||
matches := make([]Tool, 0, len(ranked))
|
||||
for _, entry := range ranked {
|
||||
matches = append(matches, entry.tool)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// noMatchMessage reports that nothing loaded and names the available deferred
|
||||
// tools so the model can retry with a valid select: query.
|
||||
func (tool toolSearchTool) noMatchMessage(query string, deferred []Tool) string {
|
||||
if len(deferred) == 0 {
|
||||
return "No deferred tools are available to load."
|
||||
}
|
||||
names := make([]string, 0, len(deferred))
|
||||
for _, candidate := range deferred {
|
||||
names = append(names, candidate.Name())
|
||||
}
|
||||
return "No tools matched \"" + query + "\". Available tools: " + strings.Join(names, ", ") +
|
||||
`. Retry with query "select:Name1,Name2" using exact names.`
|
||||
}
|
||||
|
||||
// renderLoadedTools lists each loaded tool's name, full description, and full
|
||||
// input schema (pretty-printed JSON) so the model has the complete spec inline.
|
||||
func renderLoadedTools(matches []Tool) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("Loaded ")
|
||||
if len(matches) == 1 {
|
||||
builder.WriteString("1 tool")
|
||||
} else {
|
||||
builder.WriteString(strconv.Itoa(len(matches)))
|
||||
builder.WriteString(" tools")
|
||||
}
|
||||
builder.WriteString(". Full schemas follow; call them on the next turn.\n")
|
||||
for _, match := range matches {
|
||||
builder.WriteString("\n## ")
|
||||
builder.WriteString(match.Name())
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(match.Description())
|
||||
builder.WriteString("\n")
|
||||
schemaJSON, err := json.MarshalIndent(match.Parameters(), "", " ")
|
||||
if err != nil {
|
||||
builder.WriteString("(schema unavailable)\n")
|
||||
continue
|
||||
}
|
||||
builder.WriteString("input schema:\n")
|
||||
builder.Write(schemaJSON)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// searchFakeTool is a minimal deferred-eligible tool for tool_search tests:
|
||||
// it implements Tool plus the optional Deferred() bool that IsDeferred checks.
|
||||
type searchFakeTool struct {
|
||||
name string
|
||||
description string
|
||||
parameters Schema
|
||||
}
|
||||
|
||||
func (t searchFakeTool) Name() string { return t.name }
|
||||
func (t searchFakeTool) Description() string { return t.description }
|
||||
func (t searchFakeTool) Parameters() Schema { return t.parameters }
|
||||
func (t searchFakeTool) Safety() Safety {
|
||||
return Safety{SideEffect: SideEffectRead, Permission: PermissionAllow}
|
||||
}
|
||||
func (t searchFakeTool) Run(context.Context, map[string]any) Result {
|
||||
return Result{Status: StatusOK}
|
||||
}
|
||||
func (t searchFakeTool) Deferred() bool { return true }
|
||||
|
||||
func newDeferredFixtureRegistry() *Registry {
|
||||
reg := NewRegistry()
|
||||
reg.Register(searchFakeTool{
|
||||
name: "weather_lookup",
|
||||
description: "Look up the current weather for a city.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"city": {Type: "string", Description: "City name to look up."},
|
||||
},
|
||||
Required: []string{"city"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
})
|
||||
reg.Register(searchFakeTool{
|
||||
name: "stock_quote",
|
||||
description: "Fetch a stock quote for a ticker symbol.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{"ticker": {Type: "string"}},
|
||||
Required: []string{"ticker"},
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestToolSearchExposesExpectedSafetyAndSchema(t *testing.T) {
|
||||
tool := NewToolSearchTool(NewRegistry())
|
||||
|
||||
if tool.Name() != "tool_search" {
|
||||
t.Fatalf("name = %q, want tool_search", tool.Name())
|
||||
}
|
||||
if tool.Description() == "" {
|
||||
t.Fatal("tool_search must have a description")
|
||||
}
|
||||
|
||||
safety := tool.Safety()
|
||||
if safety.SideEffect != SideEffectNone {
|
||||
t.Fatalf("side effect = %s, want none", safety.SideEffect)
|
||||
}
|
||||
if safety.Permission != PermissionAllow {
|
||||
t.Fatalf("permission = %s, want allow", safety.Permission)
|
||||
}
|
||||
if !safety.AdvertiseInAuto {
|
||||
t.Fatal("tool_search must be AdvertiseInAuto")
|
||||
}
|
||||
|
||||
schema := tool.Parameters()
|
||||
if schema.Type != "object" || schema.AdditionalProperties {
|
||||
t.Fatalf("unexpected schema header: %#v", schema)
|
||||
}
|
||||
queryProp, ok := schema.Properties["query"]
|
||||
if !ok {
|
||||
t.Fatal("schema missing query property")
|
||||
}
|
||||
if queryProp.Type != "string" {
|
||||
t.Fatalf("query type = %s, want string", queryProp.Type)
|
||||
}
|
||||
if len(schema.Required) != 1 || schema.Required[0] != "query" {
|
||||
t.Fatalf("required = %#v, want [query]", schema.Required)
|
||||
}
|
||||
}
|
||||
|
||||
// tool_search must run through the registry's optionsAwareTool dispatch and be
|
||||
// allowed without a permission grant (SideEffectNone + PermissionAllow).
|
||||
func TestToolSearchRunsThroughRegistryWithoutPermission(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
reg.Register(NewToolSearchTool(reg))
|
||||
|
||||
result := reg.Run(context.Background(), "tool_search", map[string]any{
|
||||
"query": "select:weather_lookup",
|
||||
})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("status = %s, want ok; output=%q", result.Status, result.Output)
|
||||
}
|
||||
if result.Meta["load_tools"] != "weather_lookup" {
|
||||
t.Fatalf("Meta[load_tools] = %q, want weather_lookup", result.Meta["load_tools"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchMissingQueryArgIsError(t *testing.T) {
|
||||
tool := NewToolSearchTool(NewRegistry()).(optionsAwareTool)
|
||||
result := tool.RunWithOptions(context.Background(), map[string]any{}, RunOptions{})
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("status = %s, want error for missing query", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Output, "query is required") {
|
||||
t.Fatalf("unexpected error output: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchUnknownQueryReturnsNoMeta(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
for _, query := range []string{"select:does_not_exist", "select:", "zzznomatch"} {
|
||||
result := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": query}, RunOptions{})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("query %q: status = %s, want ok (informational)", query, result.Status)
|
||||
}
|
||||
if _, present := result.Meta["load_tools"]; present {
|
||||
t.Fatalf("query %q: must NOT set load_tools, got %q", query, result.Meta["load_tools"])
|
||||
}
|
||||
// Informational message should name the available tools so the model can retry.
|
||||
if !strings.Contains(result.Output, "weather_lookup") || !strings.Contains(result.Output, "stock_quote") {
|
||||
t.Fatalf("query %q: message must name available tools, got %q", query, result.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchEmptyRegistryReportsNothingAvailable(t *testing.T) {
|
||||
tool := NewToolSearchTool(NewRegistry()).(optionsAwareTool)
|
||||
|
||||
result := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "select:anything"}, RunOptions{})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("status = %s, want ok", result.Status)
|
||||
}
|
||||
if _, present := result.Meta["load_tools"]; present {
|
||||
t.Fatalf("empty registry must not set load_tools, got %q", result.Meta["load_tools"])
|
||||
}
|
||||
if !strings.Contains(result.Output, "No deferred tools are available") {
|
||||
t.Fatalf("unexpected message: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchKeywordRanksByNameThenDescription(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
// name match should outrank a description-only match.
|
||||
reg.Register(searchFakeTool{
|
||||
name: "weather_lookup",
|
||||
description: "Look up the current weather for a city.",
|
||||
parameters: Schema{Type: "object", AdditionalProperties: false},
|
||||
})
|
||||
reg.Register(searchFakeTool{
|
||||
name: "forecast_report",
|
||||
description: "Generates a multi-day weather outlook.",
|
||||
parameters: Schema{Type: "object", AdditionalProperties: false},
|
||||
})
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
result := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "weather"}, RunOptions{})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("status = %s, want ok", result.Status)
|
||||
}
|
||||
loaded := result.Meta["load_tools"]
|
||||
// Both match "weather"; the name match (weather_lookup) must come first.
|
||||
if loaded != "weather_lookup,forecast_report" {
|
||||
t.Fatalf("load_tools = %q, want weather_lookup,forecast_report (name match ranked first)", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchKeywordExcludesNonMatches(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
|
||||
result := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "stock"}, RunOptions{})
|
||||
|
||||
if got := result.Meta["load_tools"]; got != "stock_quote" {
|
||||
t.Fatalf("load_tools = %q, want only stock_quote", got)
|
||||
}
|
||||
if strings.Contains(result.Output, "weather_lookup") {
|
||||
t.Fatalf("non-matching tool weather_lookup leaked into output: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSearchSelectLoadsExactNames(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg)
|
||||
|
||||
optioned, ok := tool.(optionsAwareTool)
|
||||
if !ok {
|
||||
t.Fatalf("tool_search must implement optionsAwareTool")
|
||||
}
|
||||
|
||||
result := optioned.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "select:weather_lookup,stock_quote"}, RunOptions{})
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("status = %s, want ok; output=%q", result.Status, result.Output)
|
||||
}
|
||||
if got := result.Meta["load_tools"]; got != "weather_lookup,stock_quote" {
|
||||
t.Fatalf("Meta[load_tools] = %q, want %q", got, "weather_lookup,stock_quote")
|
||||
}
|
||||
// Output must carry the FULL description and full schema (not a compact line).
|
||||
if !strings.Contains(result.Output, "Look up the current weather for a city.") {
|
||||
t.Fatalf("output missing full description: %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "weather_lookup") || !strings.Contains(result.Output, "\"city\"") {
|
||||
t.Fatalf("output missing full schema for weather_lookup: %q", result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "stock_quote") || !strings.Contains(result.Output, "\"ticker\"") {
|
||||
t.Fatalf("output missing full schema for stock_quote: %q", result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// A deferred tool the operator hid via DisabledTools must be invisible to
|
||||
// tool_search: it never resolves via select:, it cannot be the operator-hidden
|
||||
// candidate, and it must not appear in the no-match listing.
|
||||
func TestToolSearchExcludesDisabledDeferredTool(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
disabled := RunOptions{DisabledTools: []string{"stock_quote"}}
|
||||
|
||||
// select: a hidden tool must NOT resolve (no load_tools, informational message).
|
||||
// Query "select:stock_quote_x" avoids echoing the disabled name verbatim so the
|
||||
// listing-omission assertion below is not confused by the query echo.
|
||||
selectResult := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "select:stock_quote_x"}, disabled)
|
||||
if _, present := selectResult.Meta["load_tools"]; present {
|
||||
t.Fatalf("disabled tool must not resolve via select:, got load_tools=%q", selectResult.Meta["load_tools"])
|
||||
}
|
||||
// The available-tools listing (the segment after "Available tools: ") must omit
|
||||
// the disabled tool while still naming the allowed one.
|
||||
listing := selectResult.Output
|
||||
if idx := strings.Index(listing, "Available tools: "); idx >= 0 {
|
||||
listing = listing[idx:]
|
||||
}
|
||||
if strings.Contains(listing, "stock_quote") {
|
||||
t.Fatalf("disabled tool leaked into no-match listing: %q", selectResult.Output)
|
||||
}
|
||||
if !strings.Contains(listing, "weather_lookup") {
|
||||
t.Fatalf("no-match listing must still name the allowed tool, got %q", selectResult.Output)
|
||||
}
|
||||
|
||||
// keyword: a hidden tool must NOT rank.
|
||||
keywordResult := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "stock"}, disabled)
|
||||
if got := keywordResult.Meta["load_tools"]; got != "" {
|
||||
t.Fatalf("disabled tool must not rank for a keyword query, got load_tools=%q", got)
|
||||
}
|
||||
if strings.Contains(keywordResult.Output, "stock_quote") {
|
||||
t.Fatalf("disabled tool leaked into keyword output: %q", keywordResult.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// With a non-empty EnabledTools allowlist, a deferred tool absent from it is
|
||||
// invisible to tool_search even though it is registered and deferred-eligible.
|
||||
func TestToolSearchHonorsEnabledAllowlist(t *testing.T) {
|
||||
reg := newDeferredFixtureRegistry()
|
||||
tool := NewToolSearchTool(reg).(optionsAwareTool)
|
||||
// Allowlist admits only weather_lookup; stock_quote is excluded.
|
||||
allow := RunOptions{EnabledTools: []string{"weather_lookup"}}
|
||||
|
||||
// The excluded tool must not resolve, even by exact name.
|
||||
selectResult := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "select:weather_lookup,stock_quote"}, allow)
|
||||
if got := selectResult.Meta["load_tools"]; got != "weather_lookup" {
|
||||
t.Fatalf("allowlist must load only weather_lookup, got load_tools=%q", got)
|
||||
}
|
||||
if strings.Contains(selectResult.Output, "ticker") {
|
||||
t.Fatalf("excluded tool's schema leaked into output: %q", selectResult.Output)
|
||||
}
|
||||
|
||||
// A keyword for the excluded tool finds nothing and the listing omits it.
|
||||
keywordResult := tool.RunWithOptions(context.Background(),
|
||||
map[string]any{"query": "stock"}, allow)
|
||||
if _, present := keywordResult.Meta["load_tools"]; present {
|
||||
t.Fatalf("excluded tool must not rank under the allowlist, got %q", keywordResult.Meta["load_tools"])
|
||||
}
|
||||
if strings.Contains(keywordResult.Output, "stock_quote") {
|
||||
t.Fatalf("excluded tool leaked into keyword no-match listing: %q", keywordResult.Output)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ 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 SideEffect = "none"
|
||||
SideEffectRead SideEffect = "read"
|
||||
SideEffectWrite SideEffect = "write"
|
||||
SideEffectShell SideEffect = "shell"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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,7 +218,6 @@ 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")
|
||||
|
||||
@@ -241,7 +240,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,6 +25,7 @@ const (
|
||||
commandPlan
|
||||
commandSearch
|
||||
commandResume
|
||||
commandSpec
|
||||
commandCompact
|
||||
commandRewind
|
||||
commandEffort
|
||||
@@ -146,6 +147,13 @@ 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,7 +152,6 @@ 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()
|
||||
|
||||
+152
-8
@@ -14,10 +14,12 @@ 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"
|
||||
)
|
||||
|
||||
@@ -41,6 +43,7 @@ type model struct {
|
||||
usageTracker *usage.Tracker
|
||||
runtimeMessageSink func(tea.Msg)
|
||||
agentOptions agent.Options
|
||||
notifier *notify.Notifier
|
||||
permissionMode agent.PermissionMode
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
@@ -68,6 +71,7 @@ type model struct {
|
||||
flushRunIDs map[int]struct{}
|
||||
pendingPermission *pendingPermissionPrompt
|
||||
pendingAskUser *pendingAskUserPrompt
|
||||
pendingSpecReview *pendingSpecReviewPrompt
|
||||
width int
|
||||
height int
|
||||
now func() time.Time
|
||||
@@ -76,7 +80,10 @@ type model struct {
|
||||
themeVariant int // zeroline color theme (0-4)
|
||||
themeDark bool // zeroline light/dark
|
||||
frame int // animation frame counter (zeroline spinner)
|
||||
booted bool // zeroline boot splash finished
|
||||
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
|
||||
streamingText string // live assistant text for the current segment
|
||||
streamStartFrame int // frame the current stream segment began (tok/s)
|
||||
|
||||
@@ -119,6 +126,7 @@ type agentResponseMsg struct {
|
||||
usageEvents []zeroruntime.Usage
|
||||
usageModelID string
|
||||
sessionEvents []pendingSessionEvent
|
||||
specReview *pendingSpecReviewPrompt
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -165,6 +173,21 @@ 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()
|
||||
@@ -206,11 +229,17 @@ 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 = "❯ "
|
||||
input.Placeholder = "message zero — / commands · @ files · ! bash"
|
||||
input.Prompt = "" // the zeroline composer renders its own ❯ prompt
|
||||
input.Placeholder = "describe a task for zero…"
|
||||
}
|
||||
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,
|
||||
@@ -236,6 +265,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
input: input,
|
||||
showSplash: true,
|
||||
now: time.Now,
|
||||
notifier: notifier,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +279,24 @@ 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
|
||||
@@ -278,6 +326,9 @@ 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 {
|
||||
@@ -301,6 +352,9 @@ 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()
|
||||
}
|
||||
@@ -316,7 +370,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.picker == nil {
|
||||
if m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.picker == nil {
|
||||
m.permissionMode = nextPermissionMode(m.permissionMode)
|
||||
return m, nil
|
||||
}
|
||||
@@ -351,6 +405,9 @@ 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)
|
||||
}
|
||||
@@ -361,7 +418,6 @@ 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
|
||||
}
|
||||
@@ -372,14 +428,38 @@ 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 {
|
||||
@@ -394,6 +474,12 @@ 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 {
|
||||
@@ -487,6 +573,12 @@ 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 {
|
||||
@@ -545,6 +637,11 @@ 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 != "" {
|
||||
@@ -578,6 +675,9 @@ 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)
|
||||
@@ -804,6 +904,9 @@ 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 := ""
|
||||
@@ -988,14 +1091,28 @@ 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)
|
||||
@@ -1024,6 +1141,9 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
|
||||
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 {
|
||||
@@ -1055,6 +1175,11 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
|
||||
// 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 {
|
||||
@@ -1121,6 +1246,11 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
|
||||
|
||||
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,
|
||||
@@ -1140,6 +1270,9 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
|
||||
if result.Redacted {
|
||||
toolPayload["redacted"] = true
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
toolPayload["meta"] = result.Meta
|
||||
}
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
toolPayload["changedFiles"] = result.ChangedFiles
|
||||
}
|
||||
@@ -1190,6 +1323,17 @@ func (m model) runAgent(runID int, runCtx context.Context, prompt string, images
|
||||
})
|
||||
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,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
@@ -12,6 +13,7 @@ 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"
|
||||
@@ -55,6 +57,7 @@ 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"},
|
||||
@@ -86,7 +89,7 @@ func TestCommandRegistryResolvesAliasesAndFormatsHelp(t *testing.T) {
|
||||
}
|
||||
|
||||
help := strings.Join(formatCommandHelpLines(), "\n")
|
||||
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "model"} {
|
||||
for _, want := range []string{"/model", "/context", "/debug", "/permissions", "/spec", "model"} {
|
||||
assertContains(t, help, want)
|
||||
}
|
||||
}
|
||||
@@ -1293,3 +1296,32 @@ 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,4 +37,7 @@ 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,7 +187,6 @@ 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})
|
||||
|
||||
+11
-5
@@ -3,8 +3,11 @@ 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.
|
||||
@@ -25,11 +28,14 @@ func Run(ctx context.Context, options Options) int {
|
||||
tea.WithInput(os.Stdin),
|
||||
tea.WithOutput(os.Stdout),
|
||||
}
|
||||
// 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.
|
||||
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())
|
||||
program = tea.NewProgram(newModel(ctx, options), programOpts...)
|
||||
|
||||
if _, err := program.Run(); err != nil {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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
|
||||
}
|
||||
+104
-33
@@ -1,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,10 +14,6 @@ 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) })
|
||||
}
|
||||
@@ -32,6 +29,17 @@ 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" {
|
||||
@@ -42,6 +50,72 @@ 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.
|
||||
@@ -72,32 +146,20 @@ func (m model) zerolineView() string {
|
||||
height = 30
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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…"
|
||||
}
|
||||
|
||||
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 == "toolcall" && r.Running {
|
||||
if r.Kind == "tool" && r.Running {
|
||||
running = true
|
||||
}
|
||||
}
|
||||
@@ -119,6 +181,10 @@ 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(),
|
||||
@@ -204,11 +270,12 @@ func (m model) streamTokS() int {
|
||||
}
|
||||
|
||||
func (m model) zerolineRows() []zeroline.Row {
|
||||
// a tool call is "running" until a result row with the same id arrives
|
||||
resultIDs := make(map[string]bool)
|
||||
// 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)
|
||||
for _, r := range m.transcript {
|
||||
if r.kind == rowToolResult && r.id != "" {
|
||||
resultIDs[r.id] = true
|
||||
results[r.id] = r
|
||||
}
|
||||
}
|
||||
rows := make([]zeroline.Row, 0, len(m.transcript))
|
||||
@@ -219,14 +286,18 @@ func (m model) zerolineRows() []zeroline.Row {
|
||||
case rowAssistant:
|
||||
rows = append(rows, zeroline.Row{Kind: "assistant", Text: r.text})
|
||||
case rowToolCall:
|
||||
rows = append(rows, zeroline.Row{
|
||||
Kind: "toolcall",
|
||||
Tool: r.tool,
|
||||
Detail: r.detail,
|
||||
Running: !(r.id != "" && resultIDs[r.id]),
|
||||
})
|
||||
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)
|
||||
case rowToolResult:
|
||||
rows = append(rows, zeroline.Row{Kind: "toolresult", Tool: r.tool, Status: string(r.status), Detail: r.detail})
|
||||
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})
|
||||
case rowPermission:
|
||||
rows = append(rows, zeroline.Row{Kind: "permission", Text: r.text})
|
||||
case rowAskUser:
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroline"
|
||||
)
|
||||
|
||||
func newZerolineModel() model {
|
||||
@@ -20,7 +21,6 @@ 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(), "Own your agent") {
|
||||
if !strings.Contains(m.View(), "std-lib-first") {
|
||||
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", "you", "grep", "claude-sonnet-4.5", "thinking"} {
|
||||
for _, want := range []string{"WORKING", "❯", "grep", "claude-sonnet-4.5", "thinking"} {
|
||||
if !strings.Contains(chat, want) {
|
||||
t.Errorf("chat view missing %q", want)
|
||||
}
|
||||
@@ -91,13 +91,115 @@ func TestZerolinePermissionRender(t *testing.T) {
|
||||
request: agent.PermissionRequest{ToolName: "edit_file", SideEffect: "write"},
|
||||
}
|
||||
out := m.View()
|
||||
for _, want := range []string{"BLOCKED", "permission required", "edit_file", "allow", "deny"} {
|
||||
for _, want := range []string{"BLOCKED", "PERMISSION", "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
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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.
|
||||
@@ -0,0 +1,63 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
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,150 +3,12 @@ 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,
|
||||
@@ -158,7 +20,9 @@ func TestLooksLikeDiff(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssistantRowRendersMarkdown(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.
|
||||
d := ChatData{
|
||||
Variant: 0, Dark: true, Width: 100, Height: 40,
|
||||
Rows: []Row{
|
||||
@@ -166,11 +30,11 @@ func TestAssistantRowRendersMarkdown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
out := stripANSI(RenderChat(d))
|
||||
if strings.Contains(out, "**bold**") {
|
||||
t.Errorf("completed assistant markdown not rendered (raw markers present): %q", out)
|
||||
if !strings.Contains(out, "**bold**") {
|
||||
t.Errorf("assistant prose should render plainly (keep markers): %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "bold") {
|
||||
t.Errorf("assistant text missing from render: %q", out)
|
||||
if !strings.Contains(out, "- one") || !strings.Contains(out, "- two") {
|
||||
t.Errorf("assistant list lines missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+678
-433
File diff suppressed because it is too large.
Load diff
@@ -8,8 +8,11 @@ import (
|
||||
)
|
||||
|
||||
func TestThemesCount(t *testing.T) {
|
||||
if len(Themes) != 5 {
|
||||
t.Fatalf("expected 5 themes, got %d", len(Themes))
|
||||
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)
|
||||
}
|
||||
for i, th := range Themes {
|
||||
if th.Name == "" || th.Dark.Bg == "" || th.Light.Bg == "" {
|
||||
@@ -18,15 +21,15 @@ func TestThemesCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHomeAllThemes(t *testing.T) {
|
||||
func TestEmptyStateAllThemes(t *testing.T) {
|
||||
for v := 0; v < len(Themes); v++ {
|
||||
out := RenderHome(HomeData{
|
||||
out := RenderChat(ChatData{
|
||||
Variant: v, Dark: true, Width: 100, Height: 28,
|
||||
Header: Header{Cwd: "~/src/zero", Branch: "main", Model: "claude-sonnet-4.5", Provider: "anthropic"},
|
||||
Input: "❯ message zero",
|
||||
Chips: DefaultChips(),
|
||||
})
|
||||
if !strings.Contains(out, "Own your agent") {
|
||||
t.Errorf("theme %d: home missing tagline", v)
|
||||
if !strings.Contains(stripANSI(out), "std-lib-first") {
|
||||
t.Errorf("theme %d: empty state missing tagline", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,13 +40,12 @@ 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: "toolcall", Tool: "grep", Detail: "pattern: case"},
|
||||
{Kind: "toolresult", Tool: "grep", Status: "ok", Detail: "3 matches"},
|
||||
{Kind: "tool", Tool: "grep", Text: "pattern: case", Status: "ok", Detail: "internal/x.go:3:case"},
|
||||
{Kind: "assistant", Text: "Here is the plan."},
|
||||
},
|
||||
}
|
||||
out := RenderChat(d)
|
||||
for _, want := range []string{"DONE", "you", "grep", "✦ zero", "claude-sonnet-4.5"} {
|
||||
for _, want := range []string{"DONE", "❯", "grep", "claude-sonnet-4.5"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("chat render missing %q", want)
|
||||
}
|
||||
@@ -67,7 +69,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 required", "edit_file", "allow", "always", "deny"} {
|
||||
for _, want := range []string{"BLOCKED", "PERMISSION", "edit_file", "allow", "always", "deny"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("permission modal missing %q", want)
|
||||
}
|
||||
@@ -236,69 +238,24 @@ func TestPermLayoutMatchesRenderClamped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultRenderingCollapsesAndShows(t *testing.T) {
|
||||
func TestToolCardsShowContent(t *testing.T) {
|
||||
d := ChatData{
|
||||
Variant: 0, Dark: true, Width: 100, Height: 40,
|
||||
Rows: []Row{
|
||||
{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"},
|
||||
{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"},
|
||||
},
|
||||
}
|
||||
out := stripANSI(RenderChat(d))
|
||||
// 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, "line one") {
|
||||
t.Error("read card should show file content")
|
||||
}
|
||||
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_file diff body should be shown")
|
||||
t.Error("edit card should show the diff body")
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
if !strings.Contains(out, "BUILD-FAILED-HERE") || !strings.Contains(out, "✗") {
|
||||
t.Error("bash error card should surface the error output + ✗ status")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ 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.
|
||||
@@ -23,12 +27,35 @@ 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) }
|
||||
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)}
|
||||
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",
|
||||
}
|
||||
|
||||
// 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"),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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