Compare commits

..
Author SHA1 Message Date
anandh8x c6b805af1f fix(tools): reject mixed-root freeform patches 2026-08-26 01:18:34 +05:30
anandh8x 5c5d0bf8d0 Merge main into feat/runtime-performance 2026-08-25 21:04:28 +05:30
anandh8x 94ff28f5cb test: keep patch fixtures out of worktree 2026-08-25 11:43:40 +05:30
anandh8x a31b8ea28d fix: bind freeform patch writes to scoped roots 2026-08-25 11:43:40 +05:30
anandh8x 8081d0e194 fix: keep run tools and patch roots consistent 2026-08-25 11:43:39 +05:30
anandh8x e4f922af5e fix: harden turn session regressions 2026-08-25 11:43:39 +05:30
anandh8x 69958aa320 fix(cli): redact MCP registration errors 2026-08-25 11:43:39 +05:30
anandh8x c77557cfaa build: restore official Bubble Tea dependency 2026-08-25 11:43:39 +05:30
anandh8x 2b582f1086 fix: address runtime review findings 2026-08-25 11:43:39 +05:30
anandh8x 70d53c1f57 fix: harden runtime review edge cases 2026-08-25 11:43:39 +05:30
anandh8x 6a7c689155 perf(agent): reduce redundant bounded-task turns 2026-08-25 11:43:39 +05:30
anandh8x 3e4ee001d7 fix(usage): expose cache efficiency details 2026-08-25 11:43:39 +05:30
anandh8x edb6db2070 fix(runtime): address performance review findings 2026-08-25 11:43:39 +05:30
anandh8x 8aa81b7139 perf(tui): render only on screen changes 2026-08-25 11:43:39 +05:30
anandh8x f683c3cdb5 fix(provider): recognize saved chat sessions 2026-08-25 11:43:39 +05:30
anandh8x 54a9ab2371 feat(provider): add native patch tool input 2026-08-25 11:43:39 +05:30
anandh8x b0947b7d8f perf(provider): reuse compatible response sessions 2026-08-25 11:43:39 +05:30
anandh8x c3ae5c4835 perf(startup): defer optional tool discovery 2026-08-25 11:43:39 +05:30
anandh8x e187290b39 refactor(tools): publish immutable registry snapshots 2026-08-25 11:43:39 +05:30
anandh8x a15ce26333 perf(tui): stop composer wakeups after idle 2026-08-25 11:43:39 +05:30
anandh8x 496637bea3 perf(tui): disable duplicate composer cursor blink 2026-08-25 11:43:39 +05:30
74 changed files with 3536 additions and 1270 deletions

No files matched your search

+7 -3
View File
@@ -371,9 +371,13 @@ go run ./cmd/zero-perf-bench
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
variable unset and set.
`zero exec` runs against official OpenAI profiles. Off by default; `0`, `false`,
or `off` disable it. A/B-benchmark it by running the same `zero-perf-bench` suite
with the variable unset and set.
Native ChatGPT Responses sessions are enabled by default. Set
`ZERO_CHATGPT_TURN_SESSION=0`, `false`, or `off` to restore stateless HTTP/SSE
transport.
### Code Quality and Security Checks
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.6
require (
charm.land/bubbles/v2 v2.1.1
charm.land/bubbletea/v2 v2.0.8
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.5
github.com/Microsoft/go-winio v0.6.2
github.com/alecthomas/chroma/v2 v2.27.0
+2 -4
View File
@@ -1,7 +1,7 @@
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -64,8 +64,6 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+17 -172
View File
@@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -146,11 +144,17 @@ func estimateToolDefTokens(tools []zeroruntime.ToolDefinition) int {
for _, tool := range tools {
total += ApproxTextTokens(tool.Name)
total += ApproxTextTokens(tool.Description)
total += ApproxTextTokens(string(tool.Type))
if len(tool.Parameters) > 0 {
if encoded, err := json.Marshal(tool.Parameters); err == nil {
total += ApproxTextTokens(string(encoded))
}
}
if tool.Format != nil {
total += ApproxTextTokens(tool.Format.Type)
total += ApproxTextTokens(tool.Format.Syntax)
total += ApproxTextTokens(tool.Format.Definition)
}
total += 4 // per-tool overhead
}
return total
@@ -392,27 +396,6 @@ type compactionState struct {
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
// summarizerFactory lazily builds the dedicated (cheap) summarizer provider
// (Options.Summarizer) for the current main model. summarizerProvider
// memoizes the built provider and summarizerResolved records that the
// factory ran (it may legitimately return no provider: the main model is
// already the cheap one). summarizerBroken is the sticky fallback: once a
// build or call fails, the run stays on the main provider until the next
// model switch — a misconfigured cheap model must never break (or
// repeatedly delay) compaction.
summarizerFactory func(ctx context.Context, mainModelID string) (Provider, error)
summarizerProvider Provider
summarizerResolved bool
summarizerBroken bool
// window is the context window the threshold was derived from. The
// reactive free-prune stage compares against it (the limit the provider
// enforces), not against the 70% proactive threshold — a pruned history
// between the two would still succeed on retry.
window int
// mainModel is the model the run currently streams on; the summarizer
// choice is made against it and remade after a mid-run switch.
mainModel string
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
@@ -447,88 +430,17 @@ func (state *compactionState) calibratedTokens(raw int) int {
func newCompactionState(options Options, task *taskState) *compactionState {
state := &compactionState{
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
window: options.ContextWindow,
mainModel: options.Model,
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
summarizerFactory: options.Summarizer,
enabled: options.ContextWindow > 0,
threshold: compactionThreshold(options.ContextWindow),
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
}
// updateWindow re-derives the compaction threshold after a mid-run model
// switch. The low-water mark is reset: it was measured against the old
// threshold, and keeping it could suppress a compaction the smaller window
// now requires. <= 0 (unknown model) leaves everything unchanged.
func (state *compactionState) updateWindow(window int) {
if window <= 0 {
return
}
state.enabled = true
state.window = window
state.threshold = compactionThreshold(window)
state.lowWaterMark = 0
if state.planner != nil {
state.planner.config.contextWindow = window
}
}
// switchModel records a mid-run model switch: the window is re-derived
// (window <= 0 keeps the old one) and the summarizer choice is forgotten so
// the next compaction re-resolves it against the new main model. A run that
// started on the cheap model (no dedicated summarizer) and escalated to an
// expensive one therefore regains cheap summaries — the exact workflow a
// dedicated summarizer exists for.
func (state *compactionState) switchModel(modelID string, window int) {
state.mainModel = modelID
state.updateWindow(window)
state.summarizerProvider = nil
state.summarizerResolved = false
state.summarizerBroken = false
}
// summarizeProvider returns the provider summarization calls should use and
// whether it is the dedicated summarizer: that one when it applies to the
// current main model and is healthy, else the main provider. The factory runs
// at most once per main model; a build error marks the summarizer broken for
// that model. Callers branch on the boolean rather than comparing Provider
// values — interface comparison panics on a non-comparable dynamic type.
func (state *compactionState) summarizeProvider(ctx context.Context, main Provider) (Provider, bool) {
if state.summarizerFactory == nil || state.summarizerBroken {
return main, false
}
if !state.summarizerResolved {
state.summarizerResolved = true
built, err := state.summarizerFactory(ctx, state.mainModel)
if err != nil {
state.summarizerBroken = true
return main, false
}
state.summarizerProvider = built
}
if state.summarizerProvider == nil {
return main, false
}
return state.summarizerProvider, true
}
// fitsWindow reports whether a calibrated size is safely under the limit the
// provider enforces. The proactive threshold (70% of the window) is a trigger
// for the paid summarizer, not the hard limit; the reactive path compares
// against the window itself, with a small margin for estimate error, so a
// history pruned to, say, 85% of the window takes the free retry.
func (state *compactionState) fitsWindow(size int) bool {
if state.window <= 0 {
return size <= state.threshold
}
return size <= state.window-state.window/20
}
// maybeCompact runs proactive compaction at the top of a turn. It returns the
// (possibly compacted) message slice. It is a no-op when compaction is disabled,
// when the history is under threshold, or when the history has not grown past
@@ -622,34 +534,10 @@ func (state *compactionState) recover(
// context-limit error never triggers an unexpected summarization call.
return messages, false, nil
}
if !isContextLimitError(errorMessage) {
if state.reactiveAttempted {
return messages, false, nil
}
// CHEAP FIRST STAGE (mirrors maybeCompact): a context-limit failure can
// often be fixed for free by dropping superseded reads and pruning old
// tool-result bodies. It runs regardless of the one-shot reactive budget
// (it costs nothing and cannot loop: a second pass finds nothing left to
// prune). The free retry is taken only when the pruned history fits the
// window the provider enforces — otherwise a retry would fail the same
// way, so fall through to the paid summarizer in this same call.
toolTokens := estimateToolDefTokens(tools)
prunedAny := false
if pruned, reclaimed := pruneSupersededReadResults(messages); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages, prunedAny = pruned, true
}
if prunedAny {
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if state.fitsWindow(size) {
state.lowWaterMark = size
return messages, true, nil
}
}
if state.reactiveAttempted {
if !isContextLimitError(errorMessage) {
return messages, false, nil
}
@@ -689,21 +577,11 @@ func (state *compactionState) recover(
// provider call. The summary stream intentionally does NOT forward OnText (so
// compaction stays invisible on the user-facing surface), but it DOES forward
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
// The call goes to the dedicated (cheap) summarizer when one applies; if that
// call fails the summarizer is marked broken for the current model and the
// same slice is retried once on the main provider, so the caller sees at most
// one (main-provider-quality) error.
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
if state.planner == nil {
state.planner = newContextPlanner(contextPlannerConfig{})
}
return func(toSummarize []zeroruntime.Message) (string, error) {
chosen, dedicated := state.summarizeProvider(ctx, provider)
summary, err := summarizeWithPlanner(ctx, chosen, toSummarize, state.onUsage, state.planner, state.trace)
if err == nil || !dedicated {
return summary, err
}
state.summarizerBroken = true
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
}
}
@@ -787,41 +665,8 @@ func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []ze
return summary, nil
}
// summaryToolResultClamp bounds each tool-result body in the summarizer input.
// Tool outputs dominate the elided middle's bulk (build logs, file reads,
// search results), yet the summary instructions only need their gist — the
// model that ACTED on the full output already turned it into decisions the
// surrounding messages record. 2,000 chars (~500 tokens) keeps the useful
// head+tail of a result while cutting the summarizer input by 5-10x on
// tool-heavy sessions; the summarizer's own cost is the price being cut.
const summaryToolResultClamp = 2000
// summaryToolArgsClamp bounds each tool-call argument blob in the transcript:
// a write_file call carries the whole file body in its arguments.
const summaryToolArgsClamp = 500
// clampForSummary head+tail-truncates text for the summarizer transcript,
// snapping to rune boundaries so the clamp never splits a UTF-8 sequence.
func clampForSummary(text string, max int) string {
if max <= 0 || len(text) <= max {
return text
}
head := max * 3 / 4
tail := max - head
for head > 0 && !utf8.RuneStart(text[head]) {
head--
}
start := len(text) - tail
for start < len(text) && !utf8.RuneStart(text[start]) {
start++
}
return text[:head] + "\n…[" + strconv.Itoa(len(text)-head-(len(text)-start)) + " chars omitted for summarization]…\n" + text[start:]
}
// renderTranscript flattens messages into a plain-text transcript for the
// summarizer. Secret scrubbing already happened upstream at the tool boundary.
// Tool-result bodies and tool-call arguments are clamped (see the constants
// above): the summary needs what was done and decided, not the raw payloads.
func renderTranscript(messages []zeroruntime.Message) string {
lines := make([]string, 0, len(messages))
for _, message := range messages {
@@ -831,13 +676,13 @@ func renderTranscript(messages []zeroruntime.Message) string {
if len(message.ToolCalls) > 0 {
calls := make([]string, 0, len(message.ToolCalls))
for _, call := range message.ToolCalls {
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
calls = append(calls, call.Name+"("+call.Arguments+")")
}
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
}
lines = append(lines, line)
case zeroruntime.MessageRoleTool:
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
lines = append(lines, "tool result: "+message.Content)
default:
lines = append(lines, string(message.Role)+": "+message.Content)
}
-469
View File
@@ -1,469 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// compactableHistory returns a history whose middle is large enough to trip
// the proactive threshold for a 1000-token window.
func compactableHistory() []zeroruntime.Message {
return []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "sys"},
{Role: zeroruntime.MessageRoleUser, Content: strings.Repeat("u", 4000)},
{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("a", 4000)},
{Role: zeroruntime.MessageRoleUser, Content: "u2"},
{Role: zeroruntime.MessageRoleAssistant, Content: "a2"},
{Role: zeroruntime.MessageRoleUser, Content: "u3"},
}
}
// Summarization must run on the dedicated (cheap) summarizer when one is
// configured — the main provider sees no summarize traffic at all.
func TestMaybeCompactUsesDedicatedSummarizer(t *testing.T) {
cheap := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "CHEAP SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
main := &mockProvider{}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return cheap, nil
},
}, nil)
compacted := state.maybeCompact(context.Background(), main, compactableHistory(), nil)
if len(cheap.requests) == 0 {
t.Fatal("dedicated summarizer never received the summarize call")
}
if len(main.requests) != 0 {
t.Fatalf("main provider must see no summarize traffic, got %d requests", len(main.requests))
}
if factoryCalls != 1 {
t.Fatalf("summarizer factory calls = %d, want 1 (lazy, memoized)", factoryCalls)
}
if estimateTokens(compacted) >= estimateTokens(compactableHistory()) {
t.Fatal("compaction did not shrink the history")
}
found := false
for _, message := range compacted {
if strings.Contains(message.Content, "CHEAP SUMMARY") {
found = true
}
}
if !found {
t.Fatal("cheap summarizer's summary missing from the compacted history")
}
}
// A failing summarizer falls back to the main provider for the SAME slice and
// stays broken for the rest of the run — no repeated failed cheap calls.
func TestSummarizerFailureFallsBackToMainAndSticks(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY 2"}, {Type: zeroruntime.StreamEventDone}},
}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return broken, nil },
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
input := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}
summary, err := summarize(input)
if err != nil {
t.Fatalf("fallback must succeed via the main provider: %v", err)
}
if summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, want the main provider's", summary)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer requests = %d, want exactly 1", len(broken.requests))
}
if !state.summarizerBroken {
t.Fatal("a failed summarizer must be marked broken for the run")
}
// Second summarization: straight to main, no cheap retry.
if _, err := summarize(input); err != nil {
t.Fatal(err)
}
if len(broken.requests) != 1 {
t.Fatalf("broken summarizer was retried (%d requests)", len(broken.requests))
}
if len(main.requests) != 2 {
t.Fatalf("main provider requests = %d, want 2", len(main.requests))
}
}
// A factory that errors (misconfigured model) is equivalent to no summarizer:
// main provider used, factory not retried.
func TestSummarizerFactoryErrorFallsBack(t *testing.T) {
main := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "MAIN SUMMARY"},
{Type: zeroruntime.StreamEventDone},
}}}
factoryCalls := 0
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) {
factoryCalls++
return nil, errors.New("unknown model")
},
}, nil)
summarize := state.summarizeClosure(context.Background(), main)
summary, err := summarize([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("summary = %q, err = %v; want main provider fallback", summary, err)
}
if factoryCalls != 1 {
t.Fatalf("factory calls = %d, want 1 (sticky failure)", factoryCalls)
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("after a factory failure the run must stay on the main provider")
}
if factoryCalls != 1 {
t.Fatalf("factory retried after failure (%d calls)", factoryCalls)
}
}
// Tool payloads are clamped in the summarizer transcript: results to
// summaryToolResultClamp, call arguments to summaryToolArgsClamp.
func TestRenderTranscriptClampsToolPayloads(t *testing.T) {
bigResult := strings.Repeat("r", 50_000)
bigArgs := strings.Repeat("w", 20_000)
transcript := renderTranscript([]zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "c1", Name: "write_file", Arguments: bigArgs},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "c1", Content: bigResult},
{Role: zeroruntime.MessageRoleUser, Content: "keep going"},
})
if len(transcript) > summaryToolResultClamp+summaryToolArgsClamp+1024 {
t.Fatalf("transcript is %d bytes; clamps did not apply", len(transcript))
}
if !strings.Contains(transcript, "chars omitted for summarization") {
t.Fatal("clamped transcript must carry the omission marker")
}
if !strings.Contains(transcript, "keep going") {
t.Fatal("non-tool content must survive unclamped")
}
// Head AND tail of the clamped payloads survive.
if !strings.HasPrefix(transcript[strings.Index(transcript, "tool result: ")+len("tool result: "):], "r") ||
!strings.Contains(transcript, "rrr\n\nuser: keep going") {
t.Fatalf("head/tail of the tool result lost:\n%s", transcript[:200])
}
}
// bigToolHistory returns a history with n large tool results (~40k estimated
// tokens each): the trailing protection window keeps the newest verbatim,
// the older ones are prunable.
func bigToolHistory(n int) []zeroruntime.Message {
bigBody := strings.Repeat("x", 160_000)
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "sys"}}
for i := 0; i < n; i++ {
id := string(rune('a' + i))
messages = append(messages,
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: id, Name: "read_file", Arguments: "{\"path\":\"" + id + "\"}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: id, Content: bigBody},
)
}
return append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "go"})
}
func summaryProvider(text string) *mockProvider {
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
{{Type: zeroruntime.StreamEventText, Content: text}, {Type: zeroruntime.StreamEventDone}},
}}
}
// A context-limit error is first answered with the free prune stage; when the
// pruned history fits the window the retry is free (no summarizer call) and
// the one-shot reactive budget is untouched. The paid summarizer only runs
// when a later error finds nothing left to prune.
func TestRecoverPrunesBeforeSummarizing(t *testing.T) {
provider := summaryProvider("SUMMARY")
// The protected trailing tool output (~40k tokens) must fit the window.
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("prunable history that fits must produce a free retry")
}
if len(provider.requests) != 0 {
t.Fatalf("free prune stage must not call the summarizer (%d requests)", len(provider.requests))
}
if state.reactiveAttempted {
t.Fatal("a free prune retry must not consume the reactive budget")
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// Second context-limit error on the pruned history: nothing meaningfully
// prunable remains, so the paid summarizer fires and consumes the budget.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("second recover must compact and retry")
}
if len(provider.requests) == 0 {
t.Fatal("second recover must reach the summarizer")
}
if !state.reactiveAttempted {
t.Fatal("a paid recover must consume the reactive budget")
}
}
// When pruning reclaims something but the pruned history still exceeds the
// window, recover falls through to the paid summarizer in the SAME call
// instead of spending a retry that would fail the same way.
func TestRecoverFallsThroughToSummarizerWhenPruneIsInsufficient(t *testing.T) {
provider := summaryProvider("SUMMARY")
// A 1,000-token window: even the protected trailing output overflows it.
state := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
messages := bigToolHistory(5)
compacted, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried {
t.Fatal("insufficient prune must still end in a retry via summarization")
}
if len(provider.requests) == 0 {
t.Fatal("insufficient prune must reach the summarizer in the same recover call")
}
if !state.reactiveAttempted {
t.Fatal("the paid summarizer consumes the reactive budget")
}
if estimateTokens(compacted) >= estimateTokens(messages) {
t.Fatal("recover did not shrink the history")
}
}
// The free prune stage is not gated behind the one-shot budget: after a paid
// reactive compaction, a later context-limit error still gets a free retry
// when pruning makes the history fit.
func TestRecoverFreePruneRunsAfterBudgetIsSpent(t *testing.T) {
provider := summaryProvider("SUMMARY")
state := newCompactionState(Options{ContextWindow: 200_000, CompactionPreserveLast: 2}, nil)
state.reactiveAttempted = true
messages := bigToolHistory(5)
pruned, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("free prune must retry without the summarizer even when the budget is spent (retried=%v, requests=%d)", retried, len(provider.requests))
}
if estimateTokens(pruned) >= estimateTokens(messages) {
t.Fatal("prune did not shrink the history")
}
// With nothing left to prune and the budget spent, the caller keeps its
// original error — recover neither loops nor summarizes.
_, retried, err = state.recover(context.Background(), provider, pruned, nil, "maximum context length exceeded")
if err != nil || retried || len(provider.requests) != 0 {
t.Fatalf("spent budget must not summarize again (retried=%v, requests=%d, err=%v)", retried, len(provider.requests), err)
}
}
// The free-retry gate compares against the window the provider enforces, not
// the 70% proactive threshold: a history pruned to ~80% of the window still
// takes the free retry.
func TestRecoverFreeRetryGateUsesWindowNotThreshold(t *testing.T) {
provider := summaryProvider("SUMMARY")
messages := bigToolHistory(5)
pruned, _ := pruneStaleToolOutput(messages, 2)
prunedTokens := estimateTokens(pruned)
// Choose a window so the pruned size sits between 70% and 95% of it.
window := prunedTokens * 100 / 80
state := newCompactionState(Options{ContextWindow: window, CompactionPreserveLast: 2}, nil)
if prunedTokens <= state.threshold {
t.Fatalf("test setup: pruned size %d must exceed the proactive threshold %d", prunedTokens, state.threshold)
}
_, retried, err := state.recover(context.Background(), provider, messages, nil, "maximum context length exceeded")
if err != nil {
t.Fatal(err)
}
if !retried || len(provider.requests) != 0 {
t.Fatalf("a history that fits the window must take the free retry (retried=%v, requests=%d)", retried, len(provider.requests))
}
}
// After a mid-run model switch the summarizer is re-resolved against the new
// main model: a run that started on the cheap model (no dedicated summarizer)
// and escalated gets a cheap summarizer, and a previously broken one is
// retried for the new model.
func TestSwitchModelReresolvesSummarizer(t *testing.T) {
cheap := summaryProvider("CHEAP SUMMARY")
main := summaryProvider("MAIN SUMMARY")
var factoryModels []string
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Model: "cheap-model",
Summarizer: func(_ context.Context, mainModelID string) (Provider, error) {
factoryModels = append(factoryModels, mainModelID)
if mainModelID == "cheap-model" {
return nil, nil // main already is the cheap model
}
return cheap, nil
},
}, nil)
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("on the cheap model the main provider must summarize")
}
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated || len(factoryModels) != 1 {
t.Fatalf("factory must run once per main model, calls=%v", factoryModels)
}
state.switchModel("big-model", 400_000)
if state.threshold != compactionThreshold(400_000) || state.window != 400_000 {
t.Fatalf("switchModel must re-derive the window, got threshold=%d window=%d", state.threshold, state.window)
}
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("after escalating to an expensive model the dedicated cheap summarizer must be used")
}
if len(factoryModels) != 2 || factoryModels[1] != "big-model" {
t.Fatalf("factory must be re-invoked with the new main model, calls=%v", factoryModels)
}
// A broken summarizer is retried after the next switch.
state.summarizerBroken = true
if _, dedicated := state.summarizeProvider(context.Background(), main); dedicated {
t.Fatal("a broken summarizer must fall back to main")
}
state.switchModel("other-model", 0)
if got, dedicated := state.summarizeProvider(context.Background(), main); !dedicated || got != Provider(cheap) {
t.Fatal("a model switch must clear the broken flag and rebuild the summarizer")
}
}
func TestCompactionUpdateWindow(t *testing.T) {
state := newCompactionState(Options{ContextWindow: 100_000, CompactionPreserveLast: 2}, nil)
state.lowWaterMark = 50_000
before := state.threshold
state.updateWindow(0) // unknown model: unchanged
if state.threshold != before || state.lowWaterMark != 50_000 {
t.Fatal("updateWindow(0) must be a no-op")
}
state.updateWindow(10_000)
if state.threshold >= before {
t.Fatalf("threshold = %d, want re-derived below %d", state.threshold, before)
}
if state.lowWaterMark != 0 {
t.Fatal("low-water mark must reset on a window change")
}
if !state.enabled {
t.Fatal("a positive window must keep compaction enabled")
}
}
// End-to-end: after escalate_model switches providers, the loop re-derives the
// context window via ContextWindowFor, visible in the OnContext report.
func TestRunEscalationRederivesContextWindow(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "big-model"})
switched := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "escalate"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
{Type: zeroruntime.StreamEventDone},
}}}
var windows []int
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
ContextWindow: 100_000,
Model: "small-model",
ModelSwitcher: func(context.Context, string) (Provider, error) { return switched, nil },
ContextWindowFor: func(modelID string) int {
if modelID == "big-model" {
return 400_000
}
return 0
},
OnContext: func(breakdown ContextBreakdown) { windows = append(windows, breakdown.ContextWindow) },
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("final answer = %q", result.FinalAnswer)
}
if len(windows) < 2 {
t.Fatalf("expected at least 2 OnContext reports, got %d", len(windows))
}
if windows[0] != 100_000 {
t.Fatalf("turn 1 window = %d, want the original 100000", windows[0])
}
if windows[len(windows)-1] != 400_000 {
t.Fatalf("post-escalation window = %d, want 400000", windows[len(windows)-1])
}
}
// nonComparableProvider has a slice field, so comparing two Provider interface
// values holding it would panic; the summarizer path must never compare them.
type nonComparableProvider struct {
inner *mockProvider
tags []string
}
func (p nonComparableProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return p.inner.StreamCompletion(ctx, request)
}
func TestSummarizeClosureNeverComparesProviderValues(t *testing.T) {
broken := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventError, Error: "boom: model not found"},
}}}
main := nonComparableProvider{inner: summaryProvider("MAIN SUMMARY"), tags: []string{"x"}}
state := newCompactionState(Options{
ContextWindow: 1000,
CompactionPreserveLast: 2,
Summarizer: func(context.Context, string) (Provider, error) { return nonComparableProvider{inner: broken}, nil },
}, nil)
summary, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}})
if err != nil || summary != "MAIN SUMMARY" {
t.Fatalf("fallback with non-comparable providers must not panic and must use main, got %q err=%v", summary, err)
}
// And with no dedicated summarizer at all the closure still works.
state = newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
if _, err := state.summarizeClosure(context.Background(), main)([]zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "history"}}); err != nil {
t.Fatal(err)
}
}
+4 -1
View File
@@ -126,8 +126,11 @@ func capCompactionBrief(brief string) (string, bool) {
func compactionToolCallLine(call zeroruntime.ToolCall) string {
detail := ""
if call.Freeform && call.Name == "apply_patch" {
detail = strings.Join(patchPaths(call.Arguments), ", ")
}
var arguments map[string]any
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
if detail == "" && json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
detail = value
+2 -1
View File
@@ -73,6 +73,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
{ID: "raw-patch", Name: "apply_patch", Freeform: true, Arguments: "*** Begin Patch\n*** Update File: internal/api.go\n*** Add File: internal/config.go\n*** End Patch\n"},
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
}},
@@ -80,7 +81,7 @@ func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *tes
}
content := projectCompactionInput(messages).messages[0].Content
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "internal/api.go", "internal/config.go", "https://example.com/spec", "Inspect the Windows implementation"} {
if !strings.Contains(content, want) {
t.Fatalf("projection missing %q: %q", want, content)
}
+21
View File
@@ -275,6 +275,27 @@ func TestEstimateToolDefTokensCountsDefinitions(t *testing.T) {
if estimateToolDefTokens(one) <= 0 {
t.Fatal("a tool definition (name + description + schema) should estimate > 0 tokens")
}
typed := append([]zeroruntime.ToolDefinition(nil), one...)
typed[0].Type = zeroruntime.ToolDefinitionFreeform
if estimateToolDefTokens(typed) <= estimateToolDefTokens(one) {
t.Fatal("a non-empty tool type must increase the estimated request cost")
}
for _, test := range []struct {
name string
format zeroruntime.ToolDefinitionFormat
}{
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: WORD"}},
} {
t.Run("format "+test.name, func(t *testing.T) {
formatted := append([]zeroruntime.ToolDefinition(nil), one...)
formatted[0].Format = &test.format
if estimateToolDefTokens(formatted) <= estimateToolDefTokens(one) {
t.Fatalf("a non-empty format %s must increase the estimated request cost", test.name)
}
})
}
two := append(append([]zeroruntime.ToolDefinition{}, one...), zeroruntime.ToolDefinition{
Name: "write_file",
Description: "Write contents to a file in the workspace.",
+4
View File
@@ -102,6 +102,10 @@ func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.To
for index, definition := range toolDefs {
copied[index] = definition
copied[index].Parameters = copySchemaMap(definition.Parameters)
if definition.Format != nil {
format := *definition.Format
copied[index].Format = &format
}
}
return copied
}
+3
View File
@@ -15,6 +15,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
}
toolDefs := []zeroruntime.ToolDefinition{{
Name: "read_file", Description: "Read a file",
Format: &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: "start: PATH"},
Parameters: map[string]any{
"type": "object",
"required": []string{"path"},
@@ -53,10 +54,12 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
toolDefs[0].Parameters["required"].([]string)[0] = "other"
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
toolDefs[0].Format.Definition = "changed"
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
}
if plan.Request.Tools[0].Name != "read_file" ||
plan.Request.Tools[0].Format == nil || plan.Request.Tools[0].Format.Definition != "start: PATH" ||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
+378
View File
@@ -0,0 +1,378 @@
package agent
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type customApplyPatchTool struct {
calls int
}
type fixedPathScope struct {
roots []string
}
func (scope fixedPathScope) Roots() []string {
return append([]string(nil), scope.roots...)
}
func newFixedPathScope(t *testing.T, roots ...string) fixedPathScope {
t.Helper()
resolved := make([]string, 0, len(roots))
for _, root := range roots {
absolute, err := filepath.Abs(root)
if err != nil {
t.Fatalf("Abs(%q): %v", root, err)
}
if physical, err := filepath.EvalSymlinks(absolute); err == nil {
absolute = physical
}
resolved = append(resolved, absolute)
}
return fixedPathScope{roots: resolved}
}
func (*customApplyPatchTool) Name() string { return "apply_patch" }
func (*customApplyPatchTool) Description() string { return "custom JSON tool" }
func (*customApplyPatchTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{"value": {Type: "string"}}}
}
func (*customApplyPatchTool) Safety() tools.Safety {
return tools.Safety{Permission: tools.PermissionAllow, SideEffect: tools.SideEffectRead}
}
func (tool *customApplyPatchTool) Run(context.Context, map[string]any) tools.Result {
tool.calls++
return tools.Result{Status: tools.StatusOK}
}
func TestFreeformApplyPatchUsesExistingToolExecutionPath(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1",
Name: "apply_patch",
Arguments: patch,
Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("freeform apply_patch status = %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatalf("read patched file: %v", err)
}
if string(content) != "hello\n" {
t.Fatalf("patched content = %q, want hello newline", content)
}
}
func TestFreeformApplyPatchSupportsGrantedExtraRoot(t *testing.T) {
root := t.TempDir()
extra := t.TempDir()
scope, err := sandbox.NewScope(root, []string{extra})
if err != nil {
t.Fatalf("NewScope: %v", err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
target := filepath.Join(extra, "hello.txt")
patch := "*** Begin Patch\n*** Add File: " + target + "\n+hello\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: root, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("freeform extra-root apply_patch status = %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read extra-root patched file: %v", err)
}
if string(content) != "hello\n" {
t.Fatalf("patched content = %q, want hello newline", content)
}
}
func TestFreeformApplyPatchRejectsMixedWorkspaceAndExtraRoots(t *testing.T) {
for _, extraHasMatchingPath := range []bool{false, true} {
name := "extra target missing"
if extraHasMatchingPath {
name = "extra target exists"
}
t.Run(name, func(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, "src"), 0o755); err != nil {
t.Fatal(err)
}
workspaceTarget := filepath.Join(workspace, "src", "a.go")
if err := os.WriteFile(workspaceTarget, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
extraTarget := filepath.Join(extra, "src", "a.go")
if extraHasMatchingPath {
if err := os.MkdirAll(filepath.Dir(extraTarget), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(extraTarget, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
}
scope, err := sandbox.NewScope(workspace, []string{extra})
if err != nil {
t.Fatal(err)
}
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
extraCreated := filepath.Join(extra, "b.go")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: src/a.go",
"@@",
"-old",
"+new",
"*** Add File: " + extraCreated,
"+extra",
"*** End Patch",
}, "\n")
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError || !strings.Contains(result.Output, "mixes workspace-relative paths with an extra write root") {
t.Fatalf("mixed-root patch result = %s: %s", result.Status, result.Output)
}
if content, err := os.ReadFile(workspaceTarget); err != nil || string(content) != "old\n" {
t.Fatalf("workspace target changed: content=%q err=%v", content, err)
}
if extraHasMatchingPath {
if content, err := os.ReadFile(extraTarget); err != nil || string(content) != "old\n" {
t.Fatalf("extra-root target changed: content=%q err=%v", content, err)
}
}
if _, err := os.Stat(extraCreated); !os.IsNotExist(err) {
t.Fatalf("mixed-root patch created extra file: %v", err)
}
})
}
}
func TestFreeformApplyPatchAcceptsOneSemanticRoot(t *testing.T) {
for _, test := range []struct {
name string
useExtraRoot bool
}{
{name: "workspace"},
{name: "extra root", useExtraRoot: true},
} {
t.Run(test.name, func(t *testing.T) {
workspace := t.TempDir()
extra := t.TempDir()
scope, err := sandbox.NewScope(workspace, []string{extra})
if err != nil {
t.Fatal(err)
}
root := workspace
if test.useExtraRoot {
root = extra
}
if err := os.MkdirAll(filepath.Join(root, "src"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "src", "a.go")
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
t.Fatal(err)
}
updatePath := target
if !test.useExtraRoot {
updatePath = filepath.Join("src", "a.go")
}
created := filepath.Join(root, "b.go")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: " + updatePath,
"@@",
"-old",
"+new",
"*** Add File: " + created,
"+created",
"*** End Patch",
}, "\n")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(workspace, scope))
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: workspace, FileTracker: tools.NewFileTracker()})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusOK {
t.Fatalf("single-root patch status = %s: %s", result.Status, result.Output)
}
if content, err := os.ReadFile(target); err != nil || string(content) != "new\n" {
t.Fatalf("updated content=%q err=%v", content, err)
}
if content, err := os.ReadFile(created); err != nil || string(content) != "created\n" {
t.Fatalf("created content=%q err=%v", content, err)
}
})
}
}
func TestFreeformApplyPatchRejectsAbsolutePathOutsideGrantedRoots(t *testing.T) {
root := t.TempDir()
granted := t.TempDir()
outside := t.TempDir()
scope := newFixedPathScope(t, root, granted)
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, scope))
target := filepath.Join(outside, "blocked.txt")
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}, PermissionModeUnsafe, Options{Cwd: root})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("ungranted absolute patch status = %s, want error: %s", result.Status, result.Output)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("ungranted absolute patch created target: %v", err)
}
}
func TestPreparedFreeformPatchRejectsIntermediateSymlinkSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating directory symlinks requires privileges on Windows")
}
root := t.TempDir()
extra := t.TempDir()
outside := t.TempDir()
intermediate := filepath.Join(extra, "nested")
if err := os.Mkdir(intermediate, 0o755); err != nil {
t.Fatalf("Mkdir: %v", err)
}
scope := newFixedPathScope(t, root, extra)
tool := tools.NewScopedApplyPatchTool(root, scope)
target := filepath.Join(intermediate, "blocked.txt")
patch := "*** Begin Patch\n*** Add File: " + target + "\n+blocked\n*** End Patch\n"
args, err := tools.PrepareFreeformApplyPatchArguments(tool, patch)
if err != nil {
t.Fatalf("PrepareFreeformApplyPatchArguments: %v", err)
}
if err := os.Remove(intermediate); err != nil {
t.Fatalf("Remove intermediate: %v", err)
}
if err := os.Symlink(outside, intermediate); err != nil {
t.Fatalf("Symlink: %v", err)
}
result := tool.Run(context.Background(), args)
if result.Status != tools.StatusError {
t.Fatalf("symlink-swapped patch status = %s, want error: %s", result.Status, result.Output)
}
if _, err := os.Stat(filepath.Join(outside, "blocked.txt")); !os.IsNotExist(err) {
t.Fatalf("symlink-swapped patch wrote outside root: %v", err)
}
}
func TestUnknownFreeformToolFailsClosed(t *testing.T) {
result, err := executeToolCall(context.Background(), tools.NewRegistry(), zeroruntime.ToolCall{
ID: "call-1", Name: "unknown", Arguments: "raw", Freeform: true,
}, PermissionModeUnsafe, Options{})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("unknown freeform status = %s, want error", result.Status)
}
}
func TestCustomApplyPatchCannotUseFreeformContract(t *testing.T) {
custom := &customApplyPatchTool{}
registry := tools.NewRegistry()
registry.Register(custom)
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1", Name: "apply_patch", Arguments: "raw patch", Freeform: true,
}, PermissionModeUnsafe, Options{})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError || !strings.Contains(result.Output, "Unsupported freeform tool call") {
t.Fatalf("custom freeform apply_patch result = %#v, want unsupported error", result)
}
if custom.calls != 0 {
t.Fatalf("custom apply_patch executed %d times", custom.calls)
}
}
func TestDeniedFreeformApplyPatchDoesNotWrite(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
patch := "*** Begin Patch\n*** Add File: denied.txt\n+must not exist\n*** End Patch\n"
result, err := executeToolCall(context.Background(), registry, zeroruntime.ToolCall{
ID: "call-1",
Name: "apply_patch",
Arguments: patch,
Freeform: true,
}, PermissionModeAsk, Options{
Cwd: root,
OnPermissionRequest: func(context.Context, PermissionRequest) (PermissionDecision, error) {
return PermissionDecision{Action: PermissionDecisionDeny, Reason: "test denial"}, nil
},
})
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("denied freeform status = %s, want error", result.Status)
}
if _, err := os.Stat(filepath.Join(root, "denied.txt")); !os.IsNotExist(err) {
t.Fatalf("denied freeform patch changed the workspace: %v", err)
}
}
func TestHistorySafeToolCallsPreservesRawFreeformArguments(t *testing.T) {
raw := "*** Begin Patch\n*** Update File: internal/main.go\n@@\n-old\n+new\n*** End Patch\n"
safe := historySafeToolCalls([]ToolCall{{
ID: "call-1", ProviderCallID: "provider-1", Name: "apply_patch", Arguments: raw, Freeform: true,
}})
if len(safe) != 1 || safe[0].Arguments != raw || safe[0].ProviderCallID != "provider-1" || !safe[0].Freeform {
t.Fatalf("freeform history changed raw call: %#v", safe)
}
}
func TestAbortedToolResultPreservesProviderCallID(t *testing.T) {
messages := appendAbortedToolResults(nil, []ToolCall{{
ID: "call-1", ProviderCallID: "provider-1", Name: "read_file",
}})
if len(messages) != 1 || messages[0].ToolCallID != "call-1" || messages[0].ToolCallProviderID != "provider-1" || !messages[0].IsError {
t.Fatalf("aborted tool result lost provider identity: %#v", messages)
}
}
+8 -9
View File
@@ -33,11 +33,11 @@ const (
// knows before spending more tool turns.
toolOnlyProgressReminderAt = 6
// planReminderTurn is the turn (1-based) by the end of which a multi-step
// task should have called update_plan; if it hasn't, a one-time reminder is
// injected. Set to 3 (not 2) so short, legitimate two-step tasks finish
// without a spurious planning nag.
planReminderTurn = 3
// planReminderTurn and planReminderToolThreshold keep bounded implementation
// work plan-free while still nudging a genuinely sustained run that has not
// established any structured progress. Both thresholds must be met.
planReminderTurn = 7
planReminderToolThreshold = 7
// planToolName is the planning tool the loop watches for by name.
planToolName = "update_plan"
@@ -591,13 +591,12 @@ func (state *guardState) planReminder(turn int) string {
return planStaleReminder(state.toolCallsSincePlanUpdate)
}
// NOT-CALLED reminder: by the end of planReminderTurn the model should have
// called update_plan if it's doing a multi-step task (>=1 other tool call).
// One-shot for the whole run.
// NOT-CALLED reminder: once both the turn and tool-call thresholds are met,
// sustained work should have called update_plan. One-shot for the whole run.
if !state.notCalledReminderSent &&
!state.planEverCalled &&
turn >= planReminderTurn &&
state.totalToolCalls >= 1 {
state.totalToolCalls >= planReminderToolThreshold {
state.notCalledReminderSent = true
return planNotCalledReminder()
}
+43 -8
View File
@@ -257,19 +257,16 @@ func TestRunDoesNotCountDroppedToolCallTurnsAsEmpty(t *testing.T) {
}
func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
const expectedThreshold = 7
if planReminderToolThreshold != expectedThreshold {
t.Fatalf("plan reminder tool threshold = %d, want %d", planReminderToolThreshold, expectedThreshold)
}
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
toolTurn("call-1", "read_file", `{"path":"notes.txt"}`), // turn 1: other tool call
toolTurn("call-2", "read_file", `{"path":"notes.txt"}`), // turn 2: still no update_plan
toolTurn("call-3", "read_file", `{"path":"notes.txt"}`), // turn 3: reminder fires here
textTurn("done"),
},
}
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold), textTurn("done"))}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
@@ -287,6 +284,44 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
}
}
func TestRunDoesNotInjectPlanReminderForBoundedToolSequence(t *testing.T) {
const expectedThreshold = 7
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
registry := tools.NewRegistry()
registry.Register(tools.NewScopedReadFileTool(root, nil))
provider := &mockProvider{turns: append(repeatedReadTurns(expectedThreshold-1), textTurn("done"))}
result, err := Run(context.Background(), "go", provider, Options{Registry: registry, MaxTurns: 12})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected final answer, got %q", result.FinalAnswer)
}
if count := countUserMessagesContaining(result.Messages, planNotCalledReminderMarker); count != 0 {
t.Fatalf("bounded tool sequence must not draw a plan reminder, got %d", count)
}
}
func TestPlanReminderWaitsForTurnThresholdAfterEnoughToolCalls(t *testing.T) {
state := guardState{totalToolCalls: 7}
if got := state.planReminder(6); got != "" {
t.Fatalf("planReminder(6) = %q, want no reminder before turn threshold", got)
}
if state.notCalledReminderSent {
t.Fatal("early turn marked the not-called reminder as sent")
}
}
func repeatedReadTurns(count int) [][]zeroruntime.StreamEvent {
turns := make([][]zeroruntime.StreamEvent, 0, count)
for range count {
turns = append(turns, toolTurn("call", "read_file", `{"path":"notes.txt"}`))
}
return turns
}
func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
root := t.TempDir()
writeAgentTestFile(t, root+"/notes.txt", "alpha")
+46 -27
View File
@@ -141,6 +141,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
options.Trace.Counter(trace.CounterInputTokens, int64(usage.InputTokens))
options.Trace.Counter(trace.CounterCachedInputTokens, int64(usage.CachedInputTokens))
options.Trace.Counter(trace.CounterCacheWriteTokens, int64(usage.CacheWriteTokens))
options.Trace.Counter(trace.CounterOutputTokens, int64(usage.OutputTokens))
}
}
@@ -697,11 +698,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
turnRequestedModel = toolResult.RequestedModel
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: toolResult.ModelOutput(),
ToolCallID: toolResult.ToolCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
Role: zeroruntime.MessageRoleTool,
Content: toolResult.ModelOutput(),
ToolCallID: toolResult.ToolCallID,
ToolCallProviderID: call.ProviderCallID,
IsError: toolResult.Status == tools.StatusError,
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
})
// Images ride a following USER message rather than the tool result
// above. Every provider drops images on a tool-role message —
@@ -844,21 +846,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// Re-derive the context window for the new model so compaction and
// the OnContext budget report track the model actually in force —
// without this a switch to a smaller-window model can overflow the
// target, and a switch to a larger one over-compacts. An unknown
// model (<= 0) keeps the original window. The compactor also
// re-resolves its dedicated summarizer against the new model.
window := 0
if options.ContextWindowFor != nil {
window = options.ContextWindowFor(turnRequestedModel)
}
if window > 0 {
options.ContextWindow = window
planner.config.contextWindow = window
}
compactor.switchModel(turnRequestedModel, window)
// 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 the switcher to also
// report the new window — out of scope for this change.
}
}
}
@@ -1045,6 +1037,9 @@ func historySafeToolCalls(calls []ToolCall) []ToolCall {
safe := make([]ToolCall, len(calls))
for i, call := range calls {
safe[i] = call
if call.Freeform {
continue
}
args := strings.TrimSpace(call.Arguments)
switch {
case args == "":
@@ -1128,8 +1123,28 @@ func decodeToolArguments(arguments string, v any) error {
}
func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCall, permissionMode PermissionMode, options Options) (ToolResult, error) {
tool, toolFound := registry.Get(call.Name)
args := map[string]any{}
if call.Arguments != "" {
if call.Freeform {
if !toolFound || !tools.IsBuiltInApplyPatch(tool) {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Unsupported freeform tool call for " + call.Name + ".",
}, nil
}
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
if err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: Invalid freeform apply_patch input: " + err.Error(),
}, nil
}
args = prepared
} else if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return ToolResult{
ToolCallID: call.ID,
@@ -1157,7 +1172,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
DenialReason: DenialFiltered,
}, nil
}
tool, toolFound := registry.Get(call.Name)
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
return ToolResult{
@@ -3220,11 +3234,15 @@ func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition,
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
return zeroruntime.ToolDefinition{
definition := zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
if tools.IsBuiltInApplyPatch(tool) {
definition.Type = zeroruntime.ToolDefinitionFreeform
}
return definition
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
@@ -3379,10 +3397,11 @@ func loadedToolsFromResult(meta map[string]string) []string {
func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Message {
for _, call := range remaining {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
IsError: true,
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
ToolCallProviderID: call.ProviderCallID,
IsError: true,
})
}
return messages
+4 -1
View File
@@ -4096,7 +4096,7 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
// caller's callback AND stamps token counters, and the run completes.
func TestRunTracingWrapperStampsUsage(t *testing.T) {
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{PromptTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 10, CompletionTokens: 40}},
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}}
@@ -4135,6 +4135,9 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
t.Fatalf("cached input token counter = %d, want 20", got)
}
if got := tr.Counter(trace.CounterCacheWriteTokens); got != 10 {
t.Fatalf("cache-write token counter = %d, want 10", got)
}
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
t.Fatalf("output token counter = %d, want 40", got)
}
+13
View File
@@ -31,6 +31,19 @@ func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Resul
return tools.Result{Status: tools.StatusOK}
}
func TestRuntimeToolDefinitionMarksOnlyBuiltInApplyPatchFreeform(t *testing.T) {
builtIn := runtimeToolDefinition(tools.NewScopedApplyPatchTool(t.TempDir(), nil))
if builtIn.Type != zeroruntime.ToolDefinitionFreeform {
t.Fatalf("built-in apply_patch type = %q, want freeform", builtIn.Type)
}
calls := 0
custom := runtimeToolDefinition(countingSchemaTool{name: "apply_patch", calls: &calls})
if custom.Type != "" {
t.Fatalf("custom apply_patch type = %q, want legacy function shape", custom.Type)
}
}
// The cache renders a tool's schema once and reuses it across calls, and its
// output is identical to the uncached path.
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
+6
View File
@@ -58,12 +58,18 @@ func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSu
for _, def := range exposed {
writeLengthPrefixed(&toolsSB, def.Name)
writeLengthPrefixed(&toolsSB, def.Description)
writeLengthPrefixed(&toolsSB, string(def.Type))
// Parameters is rendered to a canonical JSON string per tool. The
// schema-render cache in internal/agent (used by partitionToolsCached)
// guarantees the same render is byte-identical across turns for the
// same tool, so this substring is a stable hash input.
writeLengthPrefixed(&schemaSB, def.Name)
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
if def.Format != nil {
writeLengthPrefixed(&schemaSB, def.Format.Type)
writeLengthPrefixed(&schemaSB, def.Format.Syntax)
writeLengthPrefixed(&schemaSB, def.Format.Definition)
}
}
return toolsSB.String(), schemaSB.String()
}
+32
View File
@@ -73,6 +73,38 @@ func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
}
}
func TestComputePrefixFingerprintIncludesToolTypeAndFormat(t *testing.T) {
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
base := zeroruntime.ToolDefinition{Name: "apply_patch", Description: "Patch files"}
baseFingerprint := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{base})
typed := base
typed.Type = zeroruntime.ToolDefinitionFreeform
if got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{typed}); got.ToolsHash == baseFingerprint.ToolsHash {
t.Fatal("ToolsHash must change when tool Type changes")
}
formatCases := []struct {
name string
format zeroruntime.ToolDefinitionFormat
}{
{name: "type", format: zeroruntime.ToolDefinitionFormat{Type: "grammar"}},
{name: "syntax", format: zeroruntime.ToolDefinitionFormat{Syntax: "lark"}},
{name: "definition", format: zeroruntime.ToolDefinitionFormat{Definition: "start: PATCH"}},
{name: "empty", format: zeroruntime.ToolDefinitionFormat{}},
}
for _, test := range formatCases {
t.Run(test.name, func(t *testing.T) {
withFormat := base
withFormat.Format = &test.format
got := ComputePrefixFingerprint(opts, []zeroruntime.ToolDefinition{withFormat})
if got.SchemaHash == baseFingerprint.SchemaHash {
t.Fatalf("SchemaHash must change for %s format input", test.name)
}
})
}
}
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
// model addendum change) must move the base-instructions hash, which moves
+19 -18
View File
@@ -30,13 +30,10 @@ work.
read-before-edit discipline: inspect the target file and nearby callers,
tests, or config before you modify behavior. Never edit a file you have not
read.
2. **Plan.** Use update_plan for implementation or investigation that has at
least three meaningful dependent steps, then keep it current at milestones.
Mark completed work and the next in-progress step together when practical;
do not spend separate turns updating it after every file or command. Keep at
most one item in_progress. Skip plans for simple lookups, explanations, code
navigation, and other short tasks. Never create a plan after the work is
already complete merely to describe what you did.
2. **Plan.** Use update_plan for multi-component, sequencing-uncertain, or long
work. Skip it for bounded changes in one component. Update only when the
overall phase changes, keep at most one item in_progress, and never create a
plan after the work is complete.
3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -55,9 +52,9 @@ work.
edit_file, apply_patch, write_file - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
code while removing comments and redundant whitespace. Use read_file when
exact text, comments, or line numbers are needed.
- Use read_minified_file only to explore large or unfamiliar source. Use
read_file for small or likely edit targets and exact text, comments, or line
numbers. Do not read both unless a new need for exact content appears.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
@@ -76,21 +73,24 @@ work.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
over-built; if a 200-line solution could be 50, rewrite it.
over-built; if a 200-line solution could be 50, rewrite it. Once the required
behavior passes validation, stop instead of adding optional cleanup.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
## Testing gate (mandatory)
- After any change to code, verify after edits by running the project's
validators before you summarize or commit: tests, type-checks, linters, and/or
the build, as appropriate. Scope them to the change while iterating; reserve
full-suite runs for milestones.
- If you are unsure which validators apply, search the repo (Makefile, package
manifests, CI config) to find them.
- After code changes, run the project's documented appropriate validators.
Scope iteration checks, combine compatible commands when diagnostics stay
useful, and run the final set once after the last edit. Rerun only after a
change or failure.
- If unsure which entry points apply, inspect the Makefile, manifests, or CI;
do not invent a parallel flow.
- Never claim a task is done, and never commit, while validators are failing. If
they fail, fix the cause and rerun; do not paper over it. If you could not run
a validator, say so explicitly rather than implying success.
- A successful native patch confirms the edit. Reread only when the next change
needs exact content or the result was ambiguous.
## Tool use
@@ -148,6 +148,7 @@ work.
- Default to concise, skimmable output. Lead with the answer or the result.
- Use GitHub-flavored Markdown: headings to structure longer replies, fenced
code blocks for code, and `inline code` for file paths, commands, symbols, and
short snippets. Reference code as `file:line` so it is clickable.
short snippets. Reference code with file paths and include line numbers when
already known; do not look them up solely for the final summary.
- Report outcomes faithfully: if tests failed, show it; if a step was skipped,
say so; when something is done and verified, state it plainly without hedging.
+5 -6
View File
@@ -60,12 +60,11 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python
for file work. read_file, edit_file and write_file take one file per call;
a single apply_patch may span several files when they form one coherent
change. Do not batch file writes into a script. Independent tool calls
(several reads, several edit_file calls to different files) belong in the
same turn.
edit_file, apply_patch, write_file) over shelling out to cat/sed/awk/python for
file work. Use them directly; never run apply_patch through exec_command.
read_file, edit_file and write_file take one file per call, while one coherent
apply_patch may span several files. Keep independent native calls in the same
turn, and do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
@@ -29,6 +29,9 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
if got := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{Model: "gpt-5"}))), " "); !strings.Contains(got, "never run apply_patch through exec_command") {
t.Fatalf("OpenAI prompt must route edits through the native patch tool:\n%s", got)
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
+11 -1
View File
@@ -29,7 +29,10 @@ func TestMain(m *testing.M) {
}
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
prompt := strings.ToLower(buildSystemPrompt(Options{}))
prompt := strings.Join(strings.Fields(strings.ToLower(buildSystemPrompt(Options{}))), " ")
if strings.Contains(prompt, "clickable file paths") {
t.Fatalf("system prompt must not claim inline-code file paths are clickable: %s", prompt)
}
for _, want := range []string{
"read-before-edit",
@@ -44,6 +47,13 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"do not recognize",
"scaled to the work",
"comment density",
"skip it for bounded changes in one component",
"do not read both unless a new need for exact content appears",
"combine compatible commands when diagnostics stay useful",
"do not invent a parallel flow",
"a successful native patch confirms the edit",
"once the required behavior passes validation, stop",
"do not look them up solely for the final summary",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
+4 -20
View File
@@ -348,26 +348,10 @@ type Options struct {
// CompactionPreserveLast is how many trailing messages compaction keeps
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
CompactionPreserveLast int
// Summarizer, when set, lazily builds the provider used for compaction
// summarization calls — typically a cheap/fast model, since summaries at
// main-model prices are the single most expensive recurring event in a
// long run. It receives the main model currently in force and may return
// (nil, nil) when no dedicated summarizer applies to it (the main model is
// already the cheap one). Built on the first paid compaction and again
// after a mid-run model switch. Any failure (build or call) falls back to
// the run's main provider until the next switch, so a misconfigured
// summarizer can never break compaction. nil keeps today's behavior.
Summarizer func(ctx context.Context, mainModelID string) (Provider, error)
// ContextWindowFor, when set, resolves a model ID to its context window so
// the compactor can re-derive its threshold after a mid-run model switch
// (escalate_model). Without it a switch keeps compacting against the
// original model's window — overflowing a smaller target or over-compacting
// a larger one. Return <= 0 when the model is unknown (window unchanged).
ContextWindowFor func(modelID string) int
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
Registry *tools.Registry
PermissionMode PermissionMode
Autonomy string
Sandbox *sandbox.Engine
// FileTracker records per-session file read/write versions so the write tools
// can detect a file changed on disk outside Zero since it was last read. nil
// disables the check. Created once per session and threaded into every tool run.
+48 -13
View File
@@ -839,18 +839,20 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
mcpTokenStore = nil
err = nil
}
criticalMCPConfig, optionalMCPConfig := splitMCPStartupConfig(mcpConfig)
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
if len(mcpConfig.Servers) > 0 {
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
if len(criticalMCPConfig.Servers) > 0 {
runtime, registerErr := deps.registerMCPTools(context.Background(), registry, criticalMCPConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
})
}
if err != nil {
closeMCPRuntime(stderr, mcpRuntime)
return writeAppError(stderr, err.Error(), 1)
if registerErr != nil {
closeMCPRuntime(stderr, runtime)
return writeAppError(stderr, redaction.ErrorMessage(registerErr, redaction.Options{}), 1)
}
mcpRuntime = runtime
}
defer closeMCPRuntime(stderr, mcpRuntime)
// A server that could not be reached or validated is skipped, not fatal (one
@@ -909,11 +911,35 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
}
// 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 + plugins) 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.
// This first pass covers every prompt-critical tool. Optional built-in MCP
// defaults re-run the same gate after publishing their atomic batch, so a
// later catalog generation cannot miss its loader. 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)
var optionalMCPRuntime *optionalMCPStartup
var awaitToolReadiness func(context.Context)
if len(optionalMCPConfig.Servers) > 0 {
optionalMCPRuntime = startOptionalMCP(
context.Background(),
registry,
optionalMCPConfig,
mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
Execution: executionRunner,
WorkspaceRoot: workspaceRoot,
},
deps.registerMCPTools,
func() {
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
},
)
defer closeMCPRuntime(stderr, optionalMCPRuntime)
awaitToolReadiness = func(ctx context.Context) {
optionalMCPRuntime.Await(ctx, optionalMCPPromptGrace)
}
}
lastKnownMCPConfig := mcpConfig
fileTracker := tools.NewFileTracker()
var scratchBaseline scratchFileBaseline
@@ -948,11 +974,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
FavoriteModels: resolved.Preferences.FavoriteModels,
RecentModels: resolved.Preferences.RecentModels,
RecapsEnabled: resolved.Preferences.RecapsEnabled(),
CompactionModel: resolved.Preferences.CompactionModel,
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
NewTurnSessionProvider: func(profile config.ProviderProfile, provider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
if provider == nil {
return nil
}
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
return optimized
}
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
},
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
PrepareRunCompletionWarning: func() {
scratchBaseline = scratchFileSnapshot(workspaceRoot)
},
@@ -960,6 +994,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
return scratchFileWarning(workspaceRoot, scratchBaseline)
},
Registry: registry,
AwaitToolReadiness: awaitToolReadiness,
SessionStore: sessionStore,
PeerService: peerService,
SandboxStore: sandboxStore,
+77 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
@@ -486,6 +487,74 @@ func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
}
}
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
if err != nil {
t.Fatalf("NewPermissionStore() error = %v", err)
}
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
if err != nil {
t.Fatalf("NewTokenStore() error = %v", err)
}
started := make(chan struct{})
release := make(chan struct{})
configResult := make(chan error, 1)
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return userConfigPath, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{MaxTurns: 8}, nil
},
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
},
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
var configErr error
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
}
configResult <- configErr
close(started)
<-release
registry.Register(cliFakeMCPRegistryTool{})
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("optional MCP initialization did not start")
}
if options.AwaitToolReadiness == nil {
t.Fatal("TUI did not receive optional tool readiness barrier")
}
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
t.Fatal("optional MCP tool was published before registration completed")
}
close(release)
options.AwaitToolReadiness(ctx)
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
t.Fatal("optional MCP tool was not visible after readiness completed")
}
return 0
},
})
if exitCode != 0 {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if configErr := <-configResult; configErr != nil {
t.Fatal(configErr)
}
}
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -565,6 +634,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
t.Fatalf("NewTokenStore() error = %v", err)
}
runtimeClosed := false
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
@@ -591,7 +661,7 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
return closeFunc(func() error {
runtimeClosed = true
return nil
}), errors.New("register mcp tools failed")
}), fmt.Errorf("register mcp tools failed: %s", secret)
},
runTUI: func(ctx context.Context, options tui.Options) int {
t.Fatal("TUI should not launch after MCP registration fails")
@@ -605,6 +675,12 @@ func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
if !strings.Contains(stderr.String(), "register mcp tools failed") {
t.Fatalf("stderr missing registration error: %s", stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
}
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
}
if !runtimeClosed {
t.Fatal("partial MCP runtime was not closed after registration error")
}
+13 -14
View File
@@ -682,20 +682,16 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
Summarizer: summarizerFactory(resolved, deps.newProvider),
ContextWindowFor: func(modelID string) int {
return modelregistry.AgentContextWindow(modelContextWindow(modelRegistry, modelID))
},
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -943,6 +939,9 @@ func registerToolSearchIfEligible(registry *tools.Registry, deferThreshold int,
if deferThreshold <= 0 {
return
}
if _, registered := registry.Get(tools.ToolSearchToolName); registered {
return
}
if deferredEligibleCount(registry, permissionMode, enabledTools, disabledTools) < deferThreshold {
return
}
+15 -9
View File
@@ -267,23 +267,29 @@ func streamJSONPermissionEventType(event agent.PermissionEvent) streamjson.Event
func (writer *execEventWriter) usage(usage agent.Usage) {
if writer.format == execOutputJSON {
writer.writeJSON(map[string]any{
"type": "usage",
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"total_tokens": usage.TotalTokens(),
"type": "usage",
"prompt_tokens": usage.PromptTokens,
"completion_tokens": usage.CompletionTokens,
"cached_input_tokens": usage.CachedInputTokens,
"cache_write_tokens": usage.CacheWriteTokens,
"total_tokens": usage.TotalTokens(),
})
return
}
if writer.format == execOutputStreamJSON {
promptTokens := usage.EffectiveInputTokens()
completionTokens := usage.EffectiveOutputTokens()
cachedInputTokens := usage.CachedInputTokens
cacheWriteTokens := usage.CacheWriteTokens
totalTokens := usage.TotalTokens()
writer.writeStreamJSON(streamjson.Event{
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
TotalTokens: &totalTokens,
Type: streamjson.EventUsage,
RunID: writer.runID,
PromptTokens: &promptTokens,
CompletionTokens: &completionTokens,
CachedInputTokens: &cachedInputTokens,
CacheWriteTokens: &cacheWriteTokens,
TotalTokens: &totalTokens,
})
}
}
+36
View File
@@ -52,3 +52,39 @@ func TestExecWriterPropagatesToolResultTruncation(t *testing.T) {
})
}
}
func TestExecWriterPropagatesCacheUsage(t *testing.T) {
for _, format := range []execOutputFormat{execOutputJSON, execOutputStreamJSON} {
t.Run(string(format), func(t *testing.T) {
var stdout bytes.Buffer
writer := execEventWriter{
stdout: &stdout,
format: format,
runID: "run_usage",
streamedText: &strings.Builder{},
}
writer.usage(agent.Usage{
InputTokens: 100,
OutputTokens: 20,
PromptTokens: 100,
CompletionTokens: 20,
CachedInputTokens: 60,
CacheWriteTokens: 10,
})
if writer.err != nil {
t.Fatalf("usage: %v", writer.err)
}
var payload map[string]any
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
t.Fatalf("decode output %q: %v", stdout.String(), err)
}
cachedKey, writeKey := "cached_input_tokens", "cache_write_tokens"
if format == execOutputStreamJSON {
cachedKey, writeKey = "cachedInputTokens", "cacheWriteTokens"
}
if payload[cachedKey] != float64(60) || payload[writeKey] != float64(10) {
t.Fatalf("cache usage missing from payload: %#v", payload)
}
})
}
}
+147
View File
@@ -0,0 +1,147 @@
package cli
import (
"context"
"sync"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
const (
optionalMCPPromptGrace = time.Second
optionalMCPCloseGrace = 250 * time.Millisecond
)
// splitMCPStartupConfig keeps explicitly configured servers on the
// prompt-critical path. Only unchanged built-in defaults may initialize after
// first paint; their failure was already intentionally silent.
func splitMCPStartupConfig(cfg config.MCPConfig) (critical config.MCPConfig, optional config.MCPConfig) {
critical.Servers = map[string]config.MCPServerConfig{}
optional.Servers = map[string]config.MCPServerConfig{}
for name, server := range cfg.Servers {
if server.Disabled {
continue
}
if config.IsUnconfiguredDefault(name, server) {
optional.Servers[name] = server
continue
}
critical.Servers[name] = server
}
return critical, optional
}
type optionalMCPStartup struct {
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
runtime mcpToolRuntime
closeOnce sync.Once
runtimeCloseOnce sync.Once
closeErr error
}
func startOptionalMCP(
parent context.Context,
registry *tools.Registry,
cfg config.MCPConfig,
options mcp.RegisterOptions,
register func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error),
onReady func(),
) *optionalMCPStartup {
ctx, cancel := context.WithCancel(parent)
startup := &optionalMCPStartup{cancel: cancel, done: make(chan struct{})}
go func() {
runtime, err := register(ctx, registry, cfg, options)
if err == nil && onReady != nil {
onReady()
}
startup.mu.Lock()
startup.runtime = runtime
startup.mu.Unlock()
close(startup.done)
}()
return startup
}
// Await gives optional tools one bounded opportunity to join the next turn's
// immutable registry snapshot. A timeout leaves this turn on the existing core
// tool set; a later turn observes the completed generation.
func (startup *optionalMCPStartup) Await(ctx context.Context, timeout time.Duration) bool {
if startup == nil {
return true
}
select {
case <-startup.done:
return true
default:
}
if timeout <= 0 {
select {
case <-startup.done:
return true
case <-ctx.Done():
return false
}
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-startup.done:
return true
case <-timer.C:
return false
case <-ctx.Done():
return false
}
}
func (startup *optionalMCPStartup) Close() error {
if startup == nil {
return nil
}
startup.closeOnce.Do(func() {
startup.cancel()
reaped := make(chan struct{})
go func() {
defer close(reaped)
<-startup.done
startup.closeRuntime()
}()
timer := time.NewTimer(optionalMCPCloseGrace)
defer timer.Stop()
select {
case <-reaped:
case <-timer.C:
}
})
startup.mu.Lock()
defer startup.mu.Unlock()
return startup.closeErr
}
func (startup *optionalMCPStartup) closeRuntime() {
startup.runtimeCloseOnce.Do(func() {
startup.mu.Lock()
runtime := startup.runtime
startup.mu.Unlock()
if runtime == nil {
return
}
err := runtime.Close()
startup.mu.Lock()
if err != nil && startup.closeErr == nil {
startup.closeErr = err
}
startup.mu.Unlock()
})
}
func (startup *optionalMCPStartup) Skipped() []mcp.SkippedServer {
return nil
}
+187
View File
@@ -0,0 +1,187 @@
package cli
import (
"context"
"errors"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/tools"
)
type closeTrackingMCPRuntime struct {
closed chan struct{}
err error
}
func (runtime *closeTrackingMCPRuntime) Close() error {
close(runtime.closed)
return runtime.err
}
func (*closeTrackingMCPRuntime) Skipped() []mcp.SkippedServer { return nil }
func TestSplitMCPStartupConfigDefersOnlyUnconfiguredDefaults(t *testing.T) {
defaults := config.DefaultMCPServers()
cfg := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"exa": defaults["exa"],
"docs": {Type: "http", URL: "https://docs.example.test/mcp"},
"disabled": {Type: "http", URL: "https://disabled.example.test/mcp", Disabled: true},
}}
critical, optional := splitMCPStartupConfig(cfg)
if _, ok := critical.Servers["docs"]; !ok || len(critical.Servers) != 1 {
t.Fatalf("critical servers = %#v, want only docs", critical.Servers)
}
if _, ok := optional.Servers["exa"]; !ok || len(optional.Servers) != 1 {
t.Fatalf("optional servers = %#v, want only exa", optional.Servers)
}
}
func TestOptionalMCPAwaitTimesOutThenObservesPublishedTools(t *testing.T) {
registry := tools.NewRegistry()
release := make(chan struct{})
started := make(chan struct{})
startup := startOptionalMCP(
context.Background(),
registry,
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(ctx context.Context, registry *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
close(started)
select {
case <-release:
registry.RegisterBatch([]tools.Tool{tools.NewScopedReadFileTool(t.TempDir(), nil)})
return noopMCPRuntime{}, nil
case <-ctx.Done():
return noopMCPRuntime{}, ctx.Err()
}
},
nil,
)
defer func() { _ = startup.Close() }()
<-started
if startup.Await(t.Context(), time.Millisecond) {
t.Fatal("optional startup unexpectedly completed before release")
}
if got := len(registry.All()); got != 0 {
t.Fatalf("registry published tools before readiness: %d", got)
}
close(release)
if !startup.Await(t.Context(), time.Second) {
t.Fatal("optional startup did not complete after release")
}
if _, ok := registry.Get("read_file"); !ok {
t.Fatal("completed optional startup did not publish its tool batch")
}
}
func TestOptionalMCPFailedRegistrationSkipsReadinessCallback(t *testing.T) {
readyCalls := 0
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, errors.New("connect failed")
},
func() { readyCalls++ },
)
defer func() { _ = startup.Close() }()
if !startup.Await(t.Context(), time.Second) {
t.Fatal("failed optional startup did not settle")
}
if readyCalls != 0 {
t.Fatalf("readiness callback ran after failed registration: %d", readyCalls)
}
}
func TestOptionalMCPCloseCancelsBlockedStartup(t *testing.T) {
started := make(chan struct{})
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(ctx context.Context, _ *tools.Registry, _ config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
close(started)
<-ctx.Done()
return noopMCPRuntime{}, nil
},
nil,
)
<-started
if err := startup.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestOptionalMCPCloseReturnsRuntimeErrorWithinGrace(t *testing.T) {
closeErr := errors.New("close failed")
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{}), err: closeErr}
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return runtime, nil
},
nil,
)
if !startup.Await(t.Context(), time.Second) {
t.Fatal("optional startup did not settle")
}
if err := startup.Close(); !errors.Is(err, closeErr) {
t.Fatalf("Close() error = %v, want %v", err, closeErr)
}
}
func TestOptionalMCPCloseBoundsRegistrationThatIgnoresCancellation(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
released := false
defer func() {
if !released {
close(release)
}
}()
runtime := &closeTrackingMCPRuntime{closed: make(chan struct{})}
startup := startOptionalMCP(
context.Background(),
tools.NewRegistry(),
config.MCPConfig{Servers: config.DefaultMCPServers()},
mcp.RegisterOptions{},
func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
close(started)
<-release
return runtime, nil
},
nil,
)
<-started
closeResult := make(chan error, 1)
go func() { closeResult <- startup.Close() }()
select {
case err := <-closeResult:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close blocked on registration after cancellation")
}
close(release)
released = true
select {
case <-runtime.closed:
case <-time.After(time.Second):
t.Fatal("late optional MCP runtime was not closed")
}
}
-17
View File
@@ -1,17 +0,0 @@
package cli
import (
"context"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// summarizerFactory adapts the resolved profile and the authenticated provider
// builder into agent.Options.Summarizer; the selection rules live in
// providers.CompactionSummarizerFactory, shared with the TUI.
func summarizerFactory(resolved config.ResolvedConfig, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (agent.Provider, error) {
return providers.CompactionSummarizerFactory(resolved.Provider, resolved.Preferences.CompactionModel, newProvider)
}
-6
View File
@@ -119,12 +119,6 @@ type PreferencesConfig struct {
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
Recaps *bool `json:"recaps,omitempty"`
// CompactionModel routes compaction summarization calls to this model
// instead of the session's main model (summaries at main-model prices are
// the most expensive recurring event in long runs). Empty = automatic: a
// curated cheap model on official endpoints, the main model elsewhere.
// "main" forces the main model. ZERO_COMPACTION_MODEL overrides.
CompactionModel string `json:"compactionModel,omitempty"`
}
// RecentModelEntry is one provider-qualified model selection recorded in
+4 -2
View File
@@ -176,9 +176,11 @@ func RegisterTools(ctx context.Context, registry *tools.Registry, cfg config.MCP
staged = append(staged, tool)
}
}
for _, tool := range staged {
registry.Register(tool)
registered := make([]tools.Tool, 0, len(staged))
for index := range staged {
registered = append(registered, staged[index])
}
registry.RegisterBatch(registered)
return runtime, nil
}
+56
View File
@@ -70,6 +70,62 @@ func TestRegisterToolsAddsPromptGatedMCPTools(t *testing.T) {
}
}
func TestRegisterToolsPublishesServerToolsInOneGeneration(t *testing.T) {
registry := tools.NewRegistry()
before := registry.Snapshot().Generation
client := &fakeToolClient{listed: []RemoteTool{
{Name: "lookup", Description: "Lookup documentation"},
{Name: "search", Description: "Search documentation"},
}}
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) {
return client, nil
}})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
after := registry.Snapshot()
if after.Generation != before+1 {
t.Fatalf("registry generation = %d, want %d", after.Generation, before+1)
}
for _, name := range []string{"mcp_docs_lookup", "mcp_docs_search"} {
if _, ok := registry.Get(name); !ok {
t.Fatalf("expected %s to be published in the batch", name)
}
}
}
func TestRegisterToolsPublishesNoneWhenServerToolValidationFails(t *testing.T) {
registry := tools.NewRegistry()
before := registry.Snapshot().Generation
client := &fakeToolClient{listed: []RemoteTool{
{Name: "lookup", Description: "Lookup documentation"},
{Name: "", Description: "Invalid nameless tool"},
}}
runtime, err := RegisterTools(context.Background(), registry, config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, RegisterOptions{ClientFactory: func(context.Context, Server) (ToolClient, error) {
return client, nil
}})
if err != nil {
t.Fatalf("RegisterTools() error = %v", err)
}
defer runtime.Close()
if got := registry.Snapshot().Generation; got != before {
t.Fatalf("registry generation = %d, want unchanged %d", got, before)
}
if _, ok := registry.Get("mcp_docs_lookup"); ok {
t.Fatal("valid prefix of rejected server batch was published")
}
if skipped := runtime.Skipped(); len(skipped) != 1 || skipped[0].Name != "docs" || skipped[0].Err == nil {
t.Fatalf("Skipped() = %#v, want one validation failure for docs", skipped)
}
}
func TestRegisterToolsMarksPersistentlyApprovedToolsAllow(t *testing.T) {
store, err := NewPermissionStore(StoreOptions{
FilePath: filepath.Join(t.TempDir(), "permissions.json"),
+22 -2
View File
@@ -191,6 +191,7 @@ type TurnBenchResult struct {
type TurnBenchTotals struct {
InputTokens int64 `json:"inputTokens"`
CachedInputTokens int64 `json:"cachedInputTokens"`
CacheWriteTokens int64 `json:"cacheWriteTokens"`
OutputTokens int64 `json:"outputTokens"`
ModelRequests int64 `json:"modelRequests"`
ToolCalls int64 `json:"toolCalls"`
@@ -479,6 +480,7 @@ func topLatencySources(perSpan map[string]SpanStats, top int) []LatencySource {
func aggregateTotals(totals *TurnBenchTotals, tr *trace.TurnTrace) {
totals.InputTokens += tr.Counter(trace.CounterInputTokens)
totals.CachedInputTokens += tr.Counter(trace.CounterCachedInputTokens)
totals.CacheWriteTokens += tr.Counter(trace.CounterCacheWriteTokens)
totals.OutputTokens += tr.Counter(trace.CounterOutputTokens)
totals.ModelRequests += tr.Counter(trace.CounterModelRequests)
totals.ToolCalls += tr.Counter(trace.CounterToolCalls)
@@ -531,8 +533,9 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
lines = append(lines, fmt.Sprintf(" %-18s %10s %5.1f%%", src.Span, FormatMetric(src.TotalMs, "ms"), src.Share*100))
}
}
lines = append(lines, fmt.Sprintf("totals: in=%d (cached %d) out=%d | requests=%d tools=%d retries=%d reconnects=%d compactions=%d",
result.Totals.InputTokens, result.Totals.CachedInputTokens, result.Totals.OutputTokens,
lines = append(lines, fmt.Sprintf("totals: in=%d (cache-read %d, cache-write %d, uncached %d) out=%d | requests=%d tools=%d retries=%d reconnects=%d compactions=%d",
result.Totals.InputTokens, result.Totals.CachedInputTokens, result.Totals.CacheWriteTokens,
uncachedInputTokens(result.Totals), result.Totals.OutputTokens,
result.Totals.ModelRequests, result.Totals.ToolCalls, result.Totals.Retries,
result.Totals.Reconnects, result.Totals.Compactions))
for _, class := range sortedClasses(result.PerClass) {
@@ -545,6 +548,23 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
return strings.Join(lines, "\n")
}
func uncachedInputTokens(totals TurnBenchTotals) int64 {
remaining := totals.InputTokens
if remaining <= 0 {
return 0
}
for _, cached := range []int64{totals.CachedInputTokens, totals.CacheWriteTokens} {
if cached <= 0 {
continue
}
if cached >= remaining {
return 0
}
remaining -= cached
}
return remaining
}
// classTier returns the oracle tier label a class was classified into, so the
// per-class roll-up states explicitly which oracle (if any) its "passed" count
// is measured against.
+33
View File
@@ -74,6 +74,7 @@ func TestRunTurnBenchAggregation(t *testing.T) {
"t3": cannedTrace(150, 20, 1500),
"t4": cannedTrace(300, 10, 1000),
}
canned["t1"].Counters = append(canned["t1"].Counters, trace.Counter{Name: trace.CounterCacheWriteTokens, Value: 125})
cfg := TurnBenchConfig{
Model: "fake-model",
Iterations: 1,
@@ -155,6 +156,9 @@ func TestRunTurnBenchAggregation(t *testing.T) {
if result.Totals.InputTokens != 5500 {
t.Fatalf("inputTokens = %d, want 5500", result.Totals.InputTokens)
}
if result.Totals.CacheWriteTokens != 125 {
t.Fatalf("cacheWriteTokens = %d, want 125", result.Totals.CacheWriteTokens)
}
if result.Totals.OutputTokens != 2750 {
t.Fatalf("outputTokens = %d, want 2750", result.Totals.OutputTokens)
}
@@ -334,6 +338,35 @@ func TestFormatTurnBenchSummaryNamesTopSources(t *testing.T) {
}
}
func TestUncachedInputTokensClampsInconsistentTotals(t *testing.T) {
tests := []struct {
name string
totals TurnBenchTotals
want int64
}{
{name: "normal", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 30, CacheWriteTokens: 20}, want: 50},
{name: "cached exceeds input", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 120}, want: 0},
{name: "write exceeds remainder", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 20, CacheWriteTokens: 90}, want: 0},
{name: "overflowing inconsistent counters", totals: TurnBenchTotals{CachedInputTokens: 1<<63 - 1, CacheWriteTokens: 1<<63 - 1}, want: 0},
{name: "overflowing counters with input", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: 1<<63 - 1, CacheWriteTokens: 1<<63 - 1}, want: 0},
{name: "negative cache counters ignored", totals: TurnBenchTotals{InputTokens: 100, CachedInputTokens: -20, CacheWriteTokens: -10}, want: 100},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := uncachedInputTokens(test.totals); got != test.want {
t.Fatalf("uncachedInputTokens(%+v) = %d, want %d", test.totals, got, test.want)
}
})
}
summary := FormatTurnBenchSummary(TurnBenchResult{Totals: TurnBenchTotals{
CachedInputTokens: 1<<63 - 1,
CacheWriteTokens: 1<<63 - 1,
}})
if !strings.Contains(summary, "uncached 0") {
t.Fatalf("summary did not clamp inconsistent cache totals:\n%s", summary)
}
}
func TestLoadBaselineManifest(t *testing.T) {
path := filepath.Join("manifests", "baseline.json")
set, err := LoadTaskSet(path)
-108
View File
@@ -1,108 +0,0 @@
package providers
import (
"context"
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// Compaction summarization model selection.
//
// Compaction summaries used to run on the session's main model — near a 200k
// window one summarization is a ~100k-token input call at full price, the
// single most expensive recurring event in a long run. A cheap model handles
// the "dense factual summary" task fine, so summarization is routed to one
// whenever the target is knowable.
//
// Resolution order: ZERO_COMPACTION_MODEL env > preferences.compactionModel
// config > a curated per-kind default (OFFICIAL endpoints only) > "" (use the
// main model). The literal value "main" at any level forces the main model.
// A wrong choice is never fatal: the agent falls back to the main provider on
// the first summarizer failure for the rest of the run.
// CompactionModelEnv overrides the compaction summarization model.
const CompactionModelEnv = "ZERO_COMPACTION_MODEL"
// compactionMainSentinel disables the dedicated summarizer explicitly.
const compactionMainSentinel = "main"
// Curated cheap summarizer defaults, applied only for provider kinds whose
// model catalog is guaranteed (official endpoints). Custom / *-compatible
// endpoints get NO default — their catalogs are unknowable, and a guaranteed
// failed call per run is worse than main-model prices.
const (
defaultAnthropicCompactionModel = "claude-haiku-4-5-20251001"
defaultGoogleCompactionModel = "gemini-2.5-flash-lite"
defaultOpenAICompactionModel = "gpt-4.1-mini"
)
// CompactionModelID returns the model that compaction summarization calls
// should use for the given profile, or "" to use the session's main model.
// preference is the resolved config value (preferences.compactionModel).
func CompactionModelID(profile config.ProviderProfile, preference string) string {
value := strings.TrimSpace(os.Getenv(CompactionModelEnv))
if value == "" {
value = strings.TrimSpace(preference)
}
if value != "" {
if strings.EqualFold(value, compactionMainSentinel) {
return ""
}
return value
}
return defaultCompactionModel(profile)
}
func defaultCompactionModel(profile config.ProviderProfile) string {
metadata, err := ResolveRuntimeMetadata(profile, Options{})
if err != nil {
return ""
}
var candidate string
switch metadata.ProviderKind {
case config.ProviderKindAnthropic:
candidate = defaultAnthropicCompactionModel
case config.ProviderKindGoogle:
candidate = defaultGoogleCompactionModel
case config.ProviderKindOpenAI:
candidate = defaultOpenAICompactionModel
default:
return ""
}
// Session already runs on the cheap model: a dedicated summarizer buys
// nothing and would only add a second provider instance.
if strings.EqualFold(strings.TrimSpace(metadata.APIModel), candidate) {
return ""
}
return candidate
}
// CompactionSummarizerFactory returns the lazy constructor the agent loop
// calls for its dedicated compaction summarizer (agent.Options.Summarizer).
// The compactor passes the main model in force at that moment — on the first
// paid compaction and again after a mid-run model switch — so the choice
// tracks escalations: a run that starts on the cheap model gets no dedicated
// summarizer, and gains one once it escalates to a pricier main model.
// Returning (nil, nil) means "summarize on the main provider". The factory
// itself is nil only when no provider builder exists. Shared by the exec and
// TUI front ends so the selection rules cannot drift between them.
func CompactionSummarizerFactory(base config.ProviderProfile, preference string, newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)) func(context.Context, string) (zeroruntime.Provider, error) {
if newProvider == nil {
return nil
}
return func(_ context.Context, mainModelID string) (zeroruntime.Provider, error) {
profile := base
if strings.TrimSpace(mainModelID) != "" {
profile.Model = mainModelID
}
modelID := CompactionModelID(profile, preference)
if modelID == "" {
return nil, nil
}
profile.Model = modelID
return newProvider(profile)
}
}
-113
View File
@@ -1,113 +0,0 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCompactionModelIDResolutionOrder(t *testing.T) {
anthropic := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: "claude-sonnet-4.5",
}
t.Setenv(CompactionModelEnv, "my-env-model")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-env-model" {
t.Fatalf("env must win, got %q", got)
}
t.Setenv(CompactionModelEnv, "main")
if got := CompactionModelID(anthropic, "my-config-model"); got != "" {
t.Fatalf("env 'main' must force the main model, got %q", got)
}
t.Setenv(CompactionModelEnv, "")
if got := CompactionModelID(anthropic, "my-config-model"); got != "my-config-model" {
t.Fatalf("config preference must apply when env unset, got %q", got)
}
if got := CompactionModelID(anthropic, "main"); got != "" {
t.Fatalf("config 'main' must force the main model, got %q", got)
}
}
func TestCompactionModelIDCuratedDefaults(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
anthropic := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: "claude-sonnet-4.5"}
if got := CompactionModelID(anthropic, ""); got != defaultAnthropicCompactionModel {
t.Fatalf("anthropic default = %q, want %q", got, defaultAnthropicCompactionModel)
}
google := config.ProviderProfile{Name: "google", ProviderKind: config.ProviderKindGoogle, Model: "gemini-2.5-pro"}
if got := CompactionModelID(google, ""); got != defaultGoogleCompactionModel {
t.Fatalf("google default = %q, want %q", got, defaultGoogleCompactionModel)
}
openai := config.ProviderProfile{Name: "openai", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
if got := CompactionModelID(openai, ""); got != defaultOpenAICompactionModel {
t.Fatalf("openai default = %q, want %q", got, defaultOpenAICompactionModel)
}
// Custom/compatible endpoints: catalog unknowable, no default.
compatible := config.ProviderProfile{
Name: "opengateway",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://opengateway.gitlawb.com/v1",
Model: "some-model",
}
if got := CompactionModelID(compatible, ""); got != "" {
t.Fatalf("openai-compatible must have no default, got %q", got)
}
}
func TestCompactionModelIDSkipsWhenMainIsAlreadyCheap(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
haiku := config.ProviderProfile{
Name: "anthropic",
ProviderKind: config.ProviderKindAnthropic,
Model: defaultAnthropicCompactionModel,
}
if got := CompactionModelID(haiku, ""); got != "" {
t.Fatalf("already-cheap session must not get a dedicated summarizer, got %q", got)
}
}
func TestCompactionSummarizerFactoryTracksMainModel(t *testing.T) {
t.Setenv(CompactionModelEnv, "")
base := config.ProviderProfile{Name: "anthropic", ProviderKind: config.ProviderKindAnthropic, Model: defaultAnthropicCompactionModel}
var built []string
newProvider := func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = append(built, profile.Model)
return nil, errors.New("not needed")
}
factory := CompactionSummarizerFactory(base, "", newProvider)
if factory == nil {
t.Fatal("factory must exist when a provider builder exists")
}
// Main model is the cheap default: no dedicated summarizer, nothing built.
if provider, err := factory(context.Background(), defaultAnthropicCompactionModel); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("cheap main model must yield (nil, nil) without building, got %v %v built=%v", provider, err, built)
}
// After escalating to an expensive model the cheap default is built.
if _, err := factory(context.Background(), "claude-sonnet-4-5"); err == nil || len(built) != 1 || built[0] != defaultAnthropicCompactionModel {
t.Fatalf("escalated main model must build the cheap summarizer, built=%v err=%v", built, err)
}
// An empty main model falls back to the base profile's model — the cheap
// default here, so again nothing is built.
built = nil
if provider, err := factory(context.Background(), ""); provider != nil || err != nil || len(built) != 0 {
t.Fatalf("empty main model must use the base profile, got %v %v built=%v", provider, err, built)
}
// The explicit "main" preference disables the dedicated summarizer.
if provider, err := CompactionSummarizerFactory(base, "main", newProvider)(context.Background(), "claude-sonnet-4-5"); provider != nil || err != nil {
t.Fatalf("preference main must disable the summarizer, got %v %v", provider, err)
}
if CompactionSummarizerFactory(base, "", nil) != nil {
t.Fatal("no provider builder means no factory")
}
}
+238 -60
View File
@@ -62,6 +62,7 @@ const (
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
responsesEventOutputTextDone = "response.output_text.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventCustomInputDelta = "response.custom_tool_call_input.delta"
responsesEventContentPartDone = "response.content_part.done"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventCompleted = "response.completed"
@@ -70,17 +71,37 @@ const (
responsesEventIncomplete = "response.incomplete"
)
const applyPatchLarkGrammar = `start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?
hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?
filename: /(.+)/
add_line: "+" /(.*)/ LF -> line
change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF
%import common.LF`
// responsesRequest is the wire shape POSTed to {baseURL}/responses.
type responsesRequest struct {
Model string `json:"model"`
Instructions string `json:"instructions"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
Instructions string `json:"instructions"`
PreviousResponseID string `json:"previous_response_id,omitempty"`
Input []inputItem `json:"input"`
Stream bool `json:"stream"`
Store bool `json:"store"`
ParallelToolCalls bool `json:"parallel_tool_calls"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
@@ -105,6 +126,7 @@ type inputItem struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Input string `json:"input,omitempty"`
// function_call_output fields
Output any `json:"output,omitempty"`
}
@@ -118,10 +140,11 @@ type contentItem struct {
}
type responsesTool struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
Format *zeroruntime.ToolDefinitionFormat `json:"format,omitempty"`
}
// responsesEvent is the decoded form of one Responses SSE data payload.
@@ -132,6 +155,7 @@ type responsesEvent struct {
Delta string `json:"delta,omitempty"`
// item payloads (response.output_item.added / done)
ItemID string `json:"item_id,omitempty"`
CallID string `json:"call_id,omitempty"`
// OutputIndex is a *int so a real 0 (the first output) is distinguishable from
// "absent" — a plain int defaulting to 0 dropped a no-item_id call's args (M1).
OutputIndex *int `json:"output_index,omitempty"`
@@ -150,6 +174,7 @@ type itemPayload struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
Input string `json:"input,omitempty"`
Status string `json:"status,omitempty"`
}
@@ -173,7 +198,8 @@ type outputDetails struct {
}
type inputDetails struct {
CachedTokens int `json:"cached_tokens"`
CachedTokens int `json:"cached_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
}
type errorPayload struct {
@@ -187,8 +213,10 @@ type errorPayload struct {
// response.output_item.done.
type toolCallBuilder struct {
ID string
CallID string
Name string
Arguments strings.Builder
Freeform bool
started bool
ended bool
}
@@ -197,15 +225,57 @@ type toolCallBuilder struct {
// event has been emitted. It is the Responses-API equivalent of the
// chat-completions toolState in provider.go.
type responsesState struct {
toolCalls map[string]*toolCallBuilder
usage *usagePayload
done bool
toolCalls map[string]*toolCallBuilder
toolOrder []string
text strings.Builder
responseID string
usage *usagePayload
emitted bool
done bool
}
func newResponsesState() *responsesState {
return &responsesState{toolCalls: map[string]*toolCallBuilder{}}
}
func (state *responsesState) outputInputItems() []inputItem {
items := make([]inputItem, 0, len(state.toolOrder)+1)
if state.text.Len() > 0 {
items = append(items, inputItem{
Type: "message",
Role: "assistant",
Content: []contentItem{{Type: "output_text", Text: state.text.String()}},
})
}
for _, key := range state.toolOrder {
builder := state.toolCalls[key]
if builder == nil || builder.ID == "" || builder.Name == "" {
continue
}
callID := builder.CallID
if callID == "" {
callID = builder.ID
}
itemType := "function_call"
if builder.Freeform {
itemType = "custom_tool_call"
}
item := inputItem{
Type: itemType,
ID: builder.ID,
CallID: callID,
Name: builder.Name,
}
if builder.Freeform {
item.Input = builder.Arguments.String()
} else {
item.Arguments = builder.Arguments.String()
}
items = append(items, item)
}
return items
}
// buildResponsesRequest converts a runtime CompletionRequest into the
// Responses API wire format. System messages fold into a top-level
// `instructions` field; everything else (user/assistant turns, tool
@@ -215,11 +285,14 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
return nil, errors.New("codex provider: model is required")
}
req := &responsesRequest{
Model: p.inner.model,
Stream: true,
MaxOutputTokens: p.inner.maxTokens,
Model: p.inner.model,
Stream: true,
ParallelToolCalls: true,
MaxOutputTokens: p.inner.maxTokens,
PromptCacheKey: request.PromptCacheKey,
}
instructions := []string{}
customCallIDs := map[string]string{}
for _, msg := range request.Messages {
switch msg.Role {
case zeroruntime.MessageRoleSystem:
@@ -234,13 +307,31 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
return nil, err
}
req.Input = append(req.Input, items...)
for _, call := range msg.ToolCalls {
if call.Freeform && call.ID != "" {
callID := call.ProviderCallID
if callID == "" {
callID = call.ID
}
customCallIDs[call.ID] = callID
}
}
case zeroruntime.MessageRoleTool:
if msg.ToolCallID == "" {
return nil, fmt.Errorf("codex provider: tool message missing tool call id")
}
outputType := "function_call_output"
callID := msg.ToolCallProviderID
if callID == "" {
callID = msg.ToolCallID
}
if customID, custom := customCallIDs[msg.ToolCallID]; custom {
outputType = "custom_tool_call_output"
callID = customID
}
req.Input = append(req.Input, inputItem{
Type: "function_call_output",
CallID: msg.ToolCallID,
Type: outputType,
CallID: callID,
Output: msg.Content,
})
default:
@@ -249,12 +340,25 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
}
req.Instructions = strings.Join(instructions, "\n\n")
for _, tool := range request.Tools {
req.Tools = append(req.Tools, responsesTool{
Type: "function",
Name: tool.Name,
Description: tool.Description,
Parameters: tool.Parameters,
})
definition := tool
if definition.Name == "apply_patch" && definition.Type == zeroruntime.ToolDefinitionFreeform {
definition.Description = "The apply_patch tool edits files from a raw structured patch. Use absolute file paths in patch headers to target a granted extra write root. Do not wrap the patch in JSON."
definition.Format = &zeroruntime.ToolDefinitionFormat{Type: "grammar", Syntax: "lark", Definition: applyPatchLarkGrammar}
}
wireTool := responsesTool{
Type: string(definition.Type),
Name: definition.Name,
Description: definition.Description,
Parameters: definition.Parameters,
Format: definition.Format,
}
if wireTool.Type == "" {
wireTool.Type = string(zeroruntime.ToolDefinitionFunction)
}
if wireTool.Type == string(zeroruntime.ToolDefinitionFreeform) {
wireTool.Parameters = nil
}
req.Tools = append(req.Tools, wireTool)
}
// Codex / o-series reasoning models take the effort tier nested under
// `reasoning` on the Responses API (the chat-completions `reasoning_effort`
@@ -312,13 +416,26 @@ func (p *CodexProvider) assistantInputItems(msg zeroruntime.Message) ([]inputIte
if tc.Name == "" {
return nil, errors.New("codex provider: assistant tool call missing name")
}
items = append(items, inputItem{
Type: "function_call",
ID: tc.ID,
CallID: tc.ID,
Name: tc.Name,
Arguments: tc.Arguments,
})
callID := tc.ProviderCallID
if callID == "" {
callID = tc.ID
}
itemType := "function_call"
if tc.Freeform {
itemType = "custom_tool_call"
}
item := inputItem{
Type: itemType,
ID: tc.ID,
CallID: callID,
Name: tc.Name,
}
if tc.Freeform {
item.Input = tc.Arguments
} else {
item.Arguments = tc.Arguments
}
items = append(items, item)
}
return items, nil
}
@@ -479,6 +596,18 @@ func (p *CodexProvider) emitResponsesEvent(
state.done = true
return false
}
return p.emitParsedResponsesEvent(ctx, &event, state, events)
}
// emitParsedResponsesEvent converts one already-decoded Responses event into
// zero or more runtime events. WebSocket sessions use this path to avoid
// decoding each frame twice; the SSE path retains emitResponsesEvent above.
func (p *CodexProvider) emitParsedResponsesEvent(
ctx context.Context,
event *responsesEvent,
state *responsesState,
events chan<- zeroruntime.StreamEvent,
) bool {
if event.Type == "" {
// A payload that parses as JSON but carries no `type` field is a
// malformed Responses event — the type is the discriminator that
@@ -500,12 +629,17 @@ func (p *CodexProvider) emitResponsesEvent(
// the runtime. response.incomplete is folded into a length finish at
// scan-end (state.done stays false so the wrapper emits the
// StreamEventDone with FinishReasonLength).
if event.Response != nil && event.Response.ID != "" {
state.responseID = event.Response.ID
}
return true
case responsesEventOutputItemAdded:
p.handleOutputItemAdded(ctx, &event, state, events)
p.handleOutputItemAdded(ctx, event, state, events)
return true
case responsesEventOutputTextDelta:
if event.Delta != "" {
state.emitted = true
state.text.WriteString(event.Delta)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventText,
Content: event.Delta,
@@ -517,6 +651,7 @@ func (p *CodexProvider) emitResponsesEvent(
// phase shows progress (and keeps the activity clock fresh) instead of
// looking like a hang. Requested via reasoning.summary="auto".
if event.Delta != "" {
state.emitted = true
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventReasoning,
Content: event.Delta,
@@ -524,13 +659,16 @@ func (p *CodexProvider) emitResponsesEvent(
}
return true
case responsesEventFunctionArgsDelta:
p.handleFunctionArgsDelta(ctx, &event, state, events)
p.handleToolInputDelta(ctx, event, state, events, false)
return true
case responsesEventCustomInputDelta:
p.handleToolInputDelta(ctx, event, state, events, true)
return true
case responsesEventOutputItemDone:
p.handleOutputItemDone(ctx, &event, state, events)
p.handleOutputItemDone(ctx, event, state, events)
return true
case responsesEventCompleted, responsesEventFailed:
return p.handleTerminalResponse(ctx, &event, state, events)
return p.handleTerminalResponse(ctx, event, state, events)
case responsesEventError:
message := event.Message
if event.Code != "" {
@@ -570,22 +708,38 @@ func (p *CodexProvider) handleOutputItemAdded(
return
}
switch event.Item.Type {
case "function_call":
case "function_call", "custom_tool_call":
key := p.toolCallKey(event)
if key == "" {
return
}
builder := &toolCallBuilder{
ID: key,
Name: event.Item.Name,
builder, exists := state.toolCalls[key]
if !exists {
builder = &toolCallBuilder{ID: key, Freeform: event.Item.Type == "custom_tool_call"}
state.toolCalls[key] = builder
state.toolOrder = append(state.toolOrder, key)
}
if event.Item.CallID != "" {
builder.CallID = event.Item.CallID
}
if event.Item.Name != "" {
builder.Name = event.Item.Name
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
if builder.Freeform && event.Item.Input != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(event.Item.Input)
}
state.emitted = true
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolCallProviderID: builder.CallID,
ToolName: builder.Name,
ToolCallFreeform: builder.Freeform,
})
builder.started = true
}
state.toolCalls[key] = builder
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolName: event.Item.Name,
})
builder.started = true
}
}
@@ -593,11 +747,12 @@ func (p *CodexProvider) handleOutputItemAdded(
// to the in-flight builder and emits StreamEventToolCallDelta. The
// Codex backend uses either `item_id` or `output_index` to attribute
// the delta; we honor both.
func (p *CodexProvider) handleFunctionArgsDelta(
func (p *CodexProvider) handleToolInputDelta(
ctx context.Context,
event *responsesEvent,
state *responsesState,
events chan<- zeroruntime.StreamEvent,
freeform bool,
) {
key := p.toolCallKey(event)
if key == "" {
@@ -610,10 +765,16 @@ func (p *CodexProvider) handleFunctionArgsDelta(
// an intermediate proxy). Treat the delta as the start of a new
// tool call with no known name yet — the eventual
// response.output_item.done carries the final name.
builder = &toolCallBuilder{ID: key}
builder = &toolCallBuilder{ID: key, Freeform: freeform}
state.toolCalls[key] = builder
state.toolOrder = append(state.toolOrder, key)
}
builder.Freeform = builder.Freeform || freeform
if event.CallID != "" && builder.CallID == "" {
builder.CallID = event.CallID
}
builder.Arguments.WriteString(event.Delta)
state.emitted = true
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: key,
@@ -632,7 +793,7 @@ func (p *CodexProvider) handleOutputItemDone(
state *responsesState,
events chan<- zeroruntime.StreamEvent,
) {
if event.Item == nil || event.Item.Type != "function_call" {
if event.Item == nil || (event.Item.Type != "function_call" && event.Item.Type != "custom_tool_call") {
return
}
key := p.toolCallKey(event)
@@ -641,20 +802,31 @@ func (p *CodexProvider) handleOutputItemDone(
}
builder, ok := state.toolCalls[key]
if !ok {
builder = &toolCallBuilder{ID: key}
builder = &toolCallBuilder{ID: key, Freeform: event.Item.Type == "custom_tool_call"}
state.toolCalls[key] = builder
state.toolOrder = append(state.toolOrder, key)
}
if event.Item.Name != "" {
builder.Name = event.Item.Name
}
if event.Item.Arguments != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(event.Item.Arguments)
if event.Item.CallID != "" {
builder.CallID = event.Item.CallID
}
builder.Freeform = builder.Freeform || event.Item.Type == "custom_tool_call"
finalInput := event.Item.Arguments
if builder.Freeform {
finalInput = event.Item.Input
}
if finalInput != "" && builder.Arguments.Len() == 0 {
builder.Arguments.WriteString(finalInput)
}
if !builder.started {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolName: builder.Name,
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: key,
ToolCallProviderID: builder.CallID,
ToolName: builder.Name,
ToolCallFreeform: builder.Freeform,
})
builder.started = true
}
@@ -664,6 +836,7 @@ func (p *CodexProvider) handleOutputItemDone(
ToolName: builder.Name,
})
builder.ended = true
state.emitted = true
}
// handleTerminalResponse emits the final usage + done event for a
@@ -693,6 +866,7 @@ func (p *CodexProvider) handleTerminalResponse(
return false
}
state.usage = event.Response.Usage
state.responseID = event.Response.ID
if event.Response.Usage != nil {
usage := zeroruntime.Usage{
InputTokens: event.Response.Usage.InputTokens,
@@ -700,6 +874,7 @@ func (p *CodexProvider) handleTerminalResponse(
}
if event.Response.Usage.InputTokensDetails != nil {
usage.CachedInputTokens = event.Response.Usage.InputTokensDetails.CachedTokens
usage.CacheWriteTokens = event.Response.Usage.InputTokensDetails.CacheWriteTokens
}
if event.Response.Usage.OutputTokensDetails != nil {
usage.ReasoningTokens = event.Response.Usage.OutputTokensDetails.ReasoningTokens
@@ -735,7 +910,7 @@ func (p *CodexProvider) handleTerminalResponse(
state.done = true
return false
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone, ResponseID: event.Response.ID})
state.done = true
return false
}
@@ -754,6 +929,9 @@ func (p *CodexProvider) toolCallKey(event *responsesEvent) string {
if event.Item != nil && event.Item.CallID != "" {
return event.Item.CallID
}
if event.CallID != "" {
return event.CallID
}
if event.OutputIndex != nil {
// output_index is present (a *int distinguishes a real 0 — the first output
// in the response — from "absent"), so key on it even when 0 instead of
+421
View File
@@ -0,0 +1,421 @@
package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
responsesWebSocketBetaHeader = "OpenAI-Beta"
responsesWebSocketBetaValue = "responses_websockets=2026-02-06"
responsesWebSocketRequest = "response.create"
previousResponseNotFoundCode = "previous_response_not_found"
webSocketConnectionLimitCode = "websocket_connection_limit_reached"
responsesWebSocketReadLimit = 16 << 20
)
var errCodexTurnSessionBusy = errors.New("codex turn session already has an active stream")
// NewCodexTurnSessionProvider enables one Responses WebSocket connection for
// each agent run. The session falls back to the existing HTTP/SSE provider when
// the endpoint cannot upgrade or loses its response chain.
func NewCodexTurnSessionProvider(provider *CodexProvider, caps zeroruntime.ProviderCapabilities) zeroruntime.TurnSessionProvider {
return &codexTurnSessionProvider{provider: provider, caps: caps}
}
type codexTurnSessionProvider struct {
provider *CodexProvider
caps zeroruntime.ProviderCapabilities
}
func (provider *codexTurnSessionProvider) OpenTurnSession(ctx context.Context) (zeroruntime.TurnSession, error) {
if provider == nil || provider.provider == nil || provider.provider.inner == nil {
return nil, errors.New("codex turn session requires a provider")
}
sessionCtx, cancel := context.WithCancel(ctx)
return &codexTurnSession{
ctx: sessionCtx,
cancel: cancel,
provider: provider.provider,
prewarmDone: make(chan struct{}),
}, nil
}
func (provider *codexTurnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
return provider.caps
}
type codexTurnSession struct {
ctx context.Context
cancel context.CancelFunc
provider *CodexProvider
mu sync.Mutex
connection *websocket.Conn
webSocketOff bool
lastRequest *responsesRequest
lastResponseID string
lastOutput []inputItem
streamActive bool
prewarmOnce sync.Once
prewarmDone chan struct{}
closeOnce sync.Once
}
// Prewarm starts the WebSocket handshake without blocking prompt assembly. The
// first Stream waits for this bounded attempt; failure simply selects HTTP/SSE.
func (session *codexTurnSession) Prewarm(ctx context.Context) error {
session.prewarmOnce.Do(func() {
go func() {
defer close(session.prewarmDone)
prewarmCtx, cancel := context.WithTimeout(session.ctx, prewarmTimeout)
defer cancel()
recorder := trace.FromContext(ctx)
span := recorder.Span(trace.SpanProviderPrewarm)
defer span.End()
recorder.Counter(trace.CounterPrewarmAttempts, 1)
connection, err := session.provider.dialResponsesWebSocket(prewarmCtx)
session.mu.Lock()
defer session.mu.Unlock()
if err != nil {
session.webSocketOff = true
return
}
session.connection = connection
}()
})
return nil
}
func (session *codexTurnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
_ = session.Prewarm(ctx)
select {
case <-session.prewarmDone:
case <-ctx.Done():
return nil, ctx.Err()
}
fullRequest, err := session.provider.buildResponsesRequest(request)
if err != nil {
return nil, err
}
session.mu.Lock()
if session.streamActive {
session.mu.Unlock()
return nil, errCodexTurnSessionBusy
}
if session.webSocketOff || session.connection == nil {
session.mu.Unlock()
trace.FromContext(ctx).Counter(trace.CounterResponsesHTTPFallback, 1)
return session.provider.StreamCompletion(ctx, request)
}
session.streamActive = true
connection := session.connection
wireRequest := *fullRequest
wireRequest.Type = responsesWebSocketRequest
if delta, ok := responseInputDelta(session.lastRequest, session.lastOutput, fullRequest); ok && session.lastResponseID != "" {
wireRequest.PreviousResponseID = session.lastResponseID
wireRequest.Input = delta
trace.FromContext(ctx).Counter(trace.CounterResponseChainReused, 1)
} else if session.lastRequest != nil {
trace.FromContext(ctx).Counter(trace.CounterResponseChainReset, 1)
session.lastRequest = nil
session.lastResponseID = ""
session.lastOutput = nil
}
session.mu.Unlock()
body, err := json.Marshal(wireRequest)
if err != nil {
session.releaseStream()
return nil, fmt.Errorf("encode codex websocket request: %w", err)
}
events := make(chan zeroruntime.StreamEvent, 16)
go func() {
defer close(events)
defer session.releaseStream()
session.streamWebSocket(ctx, connection, body, fullRequest, request, events)
}()
return events, nil
}
func (session *codexTurnSession) releaseStream() {
session.mu.Lock()
session.streamActive = false
session.mu.Unlock()
}
func (session *codexTurnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
return nil, zeroruntime.ErrCompactionUnsupported
}
func (session *codexTurnSession) Close() error {
session.closeOnce.Do(func() {
session.cancel()
session.prewarmOnce.Do(func() { close(session.prewarmDone) })
select {
case <-session.prewarmDone:
case <-time.After(prewarmTimeout):
}
session.mu.Lock()
connection := session.connection
session.connection = nil
session.webSocketOff = true
session.mu.Unlock()
if connection != nil {
_ = connection.CloseNow()
}
})
return nil
}
func responseInputDelta(previous *responsesRequest, output []inputItem, current *responsesRequest) ([]inputItem, bool) {
if previous == nil || current == nil || !responsesRequestPropertiesEqual(previous, current) {
return nil, false
}
prefix := make([]inputItem, 0, len(previous.Input)+len(output))
prefix = append(prefix, previous.Input...)
prefix = append(prefix, output...)
if len(current.Input) <= len(prefix) || !reflect.DeepEqual(current.Input[:len(prefix)], prefix) {
return nil, false
}
return append([]inputItem(nil), current.Input[len(prefix):]...), true
}
func responsesRequestPropertiesEqual(left, right *responsesRequest) bool {
return left.Model == right.Model &&
left.Instructions == right.Instructions &&
left.Stream == right.Stream &&
left.Store == right.Store &&
left.ParallelToolCalls == right.ParallelToolCalls &&
left.MaxOutputTokens == right.MaxOutputTokens &&
reflect.DeepEqual(left.Tools, right.Tools) &&
reflect.DeepEqual(left.Reasoning, right.Reasoning) &&
left.ServiceTier == right.ServiceTier &&
left.PromptCacheKey == right.PromptCacheKey
}
func (session *codexTurnSession) streamWebSocket(
ctx context.Context,
connection *websocket.Conn,
body []byte,
fullRequest *responsesRequest,
runtimeRequest zeroruntime.CompletionRequest,
events chan<- zeroruntime.StreamEvent,
) {
if err := connection.Write(ctx, websocket.MessageText, body); err != nil {
session.disableWebSocket(connection)
session.forwardHTTP(ctx, runtimeRequest, events)
return
}
state := newResponsesState()
for {
readCtx := ctx
cancel := func() {}
idleTimeout := session.provider.inner.streamIdleTimeout
if idleTimeout > 0 {
readCtx, cancel = context.WithTimeout(ctx, idleTimeout)
}
messageType, data, err := connection.Read(readCtx)
readTimedOut := errors.Is(readCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil
cancel()
if err != nil {
session.disableWebSocket(connection)
if ctx.Err() != nil {
return
}
if !state.emitted {
session.forwardHTTP(ctx, runtimeRequest, events)
return
}
errorMessage := err.Error()
if readTimedOut {
errorMessage = providerio.StreamTimeoutMessage(providerio.ErrStreamIdle, idleTimeout)
}
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: session.provider.redact("provider stream error: " + errorMessage),
})
return
}
if messageType != websocket.MessageText {
continue
}
var responseEvent responsesEvent
decodeErr := json.Unmarshal(data, &responseEvent)
if decodeErr == nil {
if (responseEvent.Code == previousResponseNotFoundCode || responseEvent.Code == webSocketConnectionLimitCode) &&
!state.emitted {
session.disableWebSocket(connection)
session.forwardHTTP(ctx, runtimeRequest, events)
return
}
if responseEvent.Type == responsesEventIncomplete {
session.resetResponseChain(connection)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventDone,
FinishReason: zeroruntime.FinishReasonLength,
})
return
}
}
keepReading := false
if decodeErr == nil {
keepReading = session.provider.emitParsedResponsesEvent(ctx, &responseEvent, state, events)
} else {
keepReading = session.provider.emitResponsesEvent(ctx, string(data), state, events)
}
if keepReading {
continue
}
if responseEvent.Type == responsesEventCompleted && responseEvent.Response != nil &&
responseEvent.Response.Error == nil && responseEvent.Response.Status != "failed" && state.responseID != "" {
session.mu.Lock()
if session.connection == connection && !session.webSocketOff {
requestCopy := *fullRequest
requestCopy.Input = append([]inputItem(nil), fullRequest.Input...)
session.lastRequest = &requestCopy
session.lastResponseID = state.responseID
session.lastOutput = state.outputInputItems()
}
session.mu.Unlock()
}
return
}
}
func (session *codexTurnSession) resetResponseChain(connection *websocket.Conn) {
session.mu.Lock()
defer session.mu.Unlock()
if session.connection != connection {
return
}
session.lastRequest = nil
session.lastResponseID = ""
session.lastOutput = nil
}
func (session *codexTurnSession) forwardHTTP(ctx context.Context, request zeroruntime.CompletionRequest, events chan<- zeroruntime.StreamEvent) {
trace.FromContext(ctx).Counter(trace.CounterResponsesHTTPFallback, 1)
stream, err := session.provider.StreamCompletion(ctx, request)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: session.provider.redact(err.Error()),
})
return
}
for event := range stream {
providerio.SendEvent(ctx, events, event)
}
}
func (session *codexTurnSession) disableWebSocket(connection *websocket.Conn) {
session.mu.Lock()
if session.connection == connection {
session.connection = nil
session.webSocketOff = true
session.lastRequest = nil
session.lastResponseID = ""
session.lastOutput = nil
}
session.mu.Unlock()
_ = connection.CloseNow()
}
func (provider *CodexProvider) dialResponsesWebSocket(ctx context.Context) (*websocket.Conn, error) {
endpoint, err := responsesWebSocketURL(provider.inner.endpoint)
if err != nil {
return nil, err
}
for authTry := 0; authTry < 2; authTry++ {
headers, err := provider.responsesWebSocketHeaders(ctx, authTry == 1)
if err != nil {
return nil, err
}
connection, response, err := websocket.Dial(ctx, endpoint, &websocket.DialOptions{
HTTPClient: provider.inner.httpClient,
HTTPHeader: headers,
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err == nil {
connection.SetReadLimit(responsesWebSocketReadLimit)
return connection, nil
}
if response == nil || response.StatusCode != http.StatusUnauthorized || provider.inner.oauthResolver == nil || authTry == 1 {
return nil, err
}
}
return nil, errors.New("codex websocket authentication failed")
}
func (provider *CodexProvider) responsesWebSocketHeaders(ctx context.Context, forceRefresh bool) (http.Header, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.inner.endpoint, nil)
if err != nil {
return nil, err
}
base := providerio.AuthHeaders{
APIKey: provider.inner.apiKey,
DefaultAuthHeader: "Authorization",
DefaultAuthScheme: "Bearer",
AuthHeader: provider.inner.authHeader,
AuthScheme: provider.inner.authScheme,
AuthHeaderValue: provider.inner.authHeaderValue,
CustomHeaders: provider.inner.customHeaders,
}
if resolver := provider.inner.oauthResolver; resolver != nil {
header, value, ok, err := resolver(ctx, forceRefresh)
if err != nil {
return nil, err
}
if ok {
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{CustomHeaders: provider.inner.customHeaders})
if strings.TrimSpace(header) == "" {
header = "Authorization"
}
request.Header.Set(header, value)
} else {
providerio.ApplyAuthHeaders(request, base)
}
} else {
providerio.ApplyAuthHeaders(request, base)
}
provider.injectCodexHeaders(request)
request.Header.Set(responsesWebSocketBetaHeader, responsesWebSocketBetaValue)
return request.Header.Clone(), nil
}
func responsesWebSocketURL(endpoint string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return "", err
}
switch parsed.Scheme {
case "http":
parsed.Scheme = "ws"
case "https":
parsed.Scheme = "wss"
default:
return "", fmt.Errorf("unsupported Responses websocket URL scheme %q", parsed.Scheme)
}
return parsed.String(), nil
}
@@ -0,0 +1,590 @@
package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestCodexTurnSessionChainsOnlyAppendOnlyRequests(t *testing.T) {
var mu sync.Mutex
requests := []map[string]any{}
betaHeader := ""
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
http.Error(writer, "websocket required", http.StatusUpgradeRequired)
return
}
mu.Lock()
betaHeader = request.Header.Get(responsesWebSocketBetaHeader)
mu.Unlock()
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
for index := 0; index < 3; index++ {
_, body, err := connection.Read(request.Context())
if err != nil {
return
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return
}
mu.Lock()
requests = append(requests, payload)
mu.Unlock()
switch index {
case 0:
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
`{"type":"response.output_item.added","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file"}}`,
`{"type":"response.function_call_arguments.delta","item_id":"call-1","delta":"{}"}`,
`{"type":"response.output_item.done","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file","arguments":"{}"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
case 1:
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"done"}`,
`{"type":"response.completed","response":{"id":"resp-2","status":"completed"}}`,
)
case 2:
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.completed","response":{"id":"resp-3","status":"completed"}}`,
)
}
}
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
// A disabled stream watchdog must use the parent context rather than creating
// an immediately-expired read deadline.
provider.inner.streamIdleTimeout = 0
session := openCodexSession(t, provider)
defer session.Close()
recorder := trace.NewRecorder("session-1", "run-1", "test")
recorder.Start()
streamContext := trace.WithContext(t.Context(), recorder)
base := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "Use tools carefully."},
{Role: zeroruntime.MessageRoleUser, Content: "Read the file."},
},
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
PromptCacheKey: "session-1",
}
first := collectCodexSessionEvents(t, session, base)
if !hasToolCallEnd(first, "call-1") {
t.Fatalf("first stream did not produce the tool call: %#v", first)
}
second := base
second.Messages = append(append([]zeroruntime.Message(nil), base.Messages...),
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: "call-1", Content: "contents"},
)
secondEvents := collectCodexSessionEventsContext(t, streamContext, session, second)
if got := joinedText(secondEvents); got != "done" {
t.Fatalf("second stream text = %q, want done", got)
}
third := second
third.ReasoningEffort = "high"
third.Messages = append(append([]zeroruntime.Message(nil), second.Messages...),
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Continue."},
)
collectCodexSessionEventsContext(t, streamContext, session, third)
finishedTrace := recorder.Finish()
if got := finishedTrace.Counter(trace.CounterResponseChainReused); got != 1 {
t.Fatalf("response-chain reuse count = %d, want 1", got)
}
if got := finishedTrace.Counter(trace.CounterResponseChainReset); got != 1 {
t.Fatalf("response-chain reset count = %d, want 1", got)
}
mu.Lock()
defer mu.Unlock()
if len(requests) != 3 {
t.Fatalf("websocket requests = %d, want 3", len(requests))
}
if betaHeader != responsesWebSocketBetaValue {
t.Fatalf("OpenAI-Beta = %q, want %q", betaHeader, responsesWebSocketBetaValue)
}
if requests[0]["type"] != responsesWebSocketRequest || requests[0]["previous_response_id"] != nil {
t.Fatalf("first request chaining fields = %#v", requests[0])
}
if requests[0]["parallel_tool_calls"] != true || requests[0]["prompt_cache_key"] != "session-1" {
t.Fatalf("first request stable fields = %#v", requests[0])
}
if requests[1]["previous_response_id"] != "resp-1" {
t.Fatalf("second previous_response_id = %#v", requests[1]["previous_response_id"])
}
secondInput, _ := requests[1]["input"].([]any)
if len(secondInput) != 1 || secondInput[0].(map[string]any)["type"] != "function_call_output" {
t.Fatalf("second incremental input = %#v", requests[1]["input"])
}
if requests[2]["previous_response_id"] != nil {
t.Fatalf("incompatible request reused response chain: %#v", requests[2])
}
thirdInput, _ := requests[2]["input"].([]any)
if len(thirdInput) <= 1 {
t.Fatalf("incompatible request did not send full input: %#v", thirdInput)
}
}
func TestCodexTurnSessionFallsBackWhenPreviousResponseIsMissing(t *testing.T) {
var mu sync.Mutex
webSocketRequests := 0
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
for index := 0; index < 2; index++ {
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
mu.Lock()
webSocketRequests++
mu.Unlock()
if index == 0 {
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_item.added","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file"}}`,
`{"type":"response.output_item.done","item_id":"call-1","item":{"type":"function_call","id":"call-1","call_id":"call-1","name":"read_file","arguments":"{}"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
continue
}
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.error","code":"previous_response_not_found","message":"missing"}`,
)
}
return
}
mu.Lock()
httpRequests++
mu.Unlock()
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-http\",\"status\":\"completed\"}}\n\n")
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
base := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Read it."}},
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
}
collectCodexSessionEvents(t, session, base)
second := base
second.Messages = append(append([]zeroruntime.Message(nil), base.Messages...),
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}},
zeroruntime.Message{Role: zeroruntime.MessageRoleTool, ToolCallID: "call-1", Content: "contents"},
)
events := collectCodexSessionEvents(t, session, second)
if got := joinedText(events); got != "fallback" {
t.Fatalf("fallback stream text = %q, want fallback; events=%#v", got, events)
}
for _, event := range events {
if event.Type == zeroruntime.StreamEventError {
t.Fatalf("response-chain recovery leaked an error: %#v", event)
}
}
mu.Lock()
defer mu.Unlock()
if webSocketRequests != 2 || httpRequests != 1 {
t.Fatalf("requests = websocket:%d http:%d, want 2/1", webSocketRequests, httpRequests)
}
}
func TestCodexTurnSessionFallsBackWhenSocketClosesBeforeOutput(t *testing.T) {
var mu sync.Mutex
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
if _, _, err := connection.Read(request.Context()); err == nil {
_ = connection.CloseNow()
}
return
}
mu.Lock()
httpRequests++
mu.Unlock()
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"recovered\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-http\",\"status\":\"completed\"}}\n\n")
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
recorder := trace.NewRecorder("session-fallback", "run-fallback", "test")
recorder.Start()
events := collectCodexSessionEventsContext(t, trace.WithContext(t.Context(), recorder), session, zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
})
if got := joinedText(events); got != "recovered" {
t.Fatalf("fallback text = %q, want recovered", got)
}
mu.Lock()
defer mu.Unlock()
if httpRequests != 1 {
t.Fatalf("HTTP fallback requests = %d, want 1", httpRequests)
}
if got := recorder.Finish().Counter(trace.CounterResponsesHTTPFallback); got != 1 {
t.Fatalf("responses HTTP fallback counter = %d, want 1", got)
}
}
func TestCodexTurnSessionKeepsWebSocketAfterIncompleteResponse(t *testing.T) {
var mu sync.Mutex
webSocketRequests := 0
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
for index := 0; index < 2; index++ {
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
mu.Lock()
webSocketRequests++
mu.Unlock()
if index == 0 {
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.incomplete","response":{"id":"resp-1","status":"incomplete"}}`,
)
continue
}
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"websocket"}`,
`{"type":"response.completed","response":{"id":"resp-2","status":"completed"}}`,
)
}
return
}
mu.Lock()
httpRequests++
mu.Unlock()
writer.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.output_text.delta\",\"delta\":\"fallback\"}\n\n")
_, _ = fmt.Fprint(writer, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-http\",\"status\":\"completed\"}}\n\n")
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Continue."}},
}
first := collectCodexSessionEvents(t, session, request)
if len(first) != 1 || first[0].Type != zeroruntime.StreamEventDone || first[0].FinishReason != zeroruntime.FinishReasonLength {
t.Fatalf("incomplete response events = %#v, want length completion", first)
}
second := collectCodexSessionEvents(t, session, request)
if got := joinedText(second); got != "websocket" {
t.Fatalf("second turn text = %q, want websocket; events=%#v", got, second)
}
mu.Lock()
defer mu.Unlock()
if webSocketRequests != 2 || httpRequests != 0 {
t.Fatalf("requests = websocket:%d http:%d, want 2/0", webSocketRequests, httpRequests)
}
}
func TestCodexTurnSessionDoesNotReplayAfterVisibleOutput(t *testing.T) {
var mu sync.Mutex
httpRequests := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if strings.EqualFold(request.Header.Get("Upgrade"), "websocket") {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
if _, _, err := connection.Read(request.Context()); err == nil {
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"visible"}`,
)
}
_ = connection.Close(websocket.StatusInternalError, "interrupted")
return
}
mu.Lock()
httpRequests++
mu.Unlock()
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
stream, err := session.Stream(t.Context(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
sawText, sawError := false, false
for event := range stream {
sawText = sawText || event.Type == zeroruntime.StreamEventText
sawError = sawError || event.Type == zeroruntime.StreamEventError
}
if !sawText || !sawError {
t.Fatalf("visible interrupted stream = text:%v error:%v", sawText, sawError)
}
mu.Lock()
defer mu.Unlock()
if httpRequests != 0 {
t.Fatalf("visible interrupted stream was replayed over HTTP %d time(s)", httpRequests)
}
}
func TestCodexTurnSessionReportsWebSocketIdleTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.output_text.delta","delta":"visible"}`,
)
<-request.Context().Done()
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
provider.inner.streamIdleTimeout = 200 * time.Millisecond
session := openCodexSession(t, provider)
defer session.Close()
stream, err := session.Stream(t.Context(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
var streamError string
for event := range stream {
if event.Type == zeroruntime.StreamEventError {
streamError = event.Error
break
}
}
if !strings.Contains(streamError, "idle timeout after 200ms") {
t.Fatalf("stream error = %q, want idle-timeout detail", streamError)
}
}
func TestCodexTurnSessionRejectsOverlappingStreams(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
var releaseOnce sync.Once
releaseStream := func() { releaseOnce.Do(func() { close(release) }) }
t.Cleanup(releaseStream)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
connection, err := websocket.Accept(writer, request, nil)
if err != nil {
return
}
defer connection.CloseNow()
if _, _, err := connection.Read(request.Context()); err != nil {
return
}
close(started)
<-release
writeWebSocketEvents(request.Context(), connection,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
)
}))
defer server.Close()
provider := newCodexSessionTestProvider(t, server)
session := openCodexSession(t, provider)
defer session.Close()
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Hello"}},
}
first, err := session.Stream(t.Context(), request)
if err != nil {
t.Fatalf("first Stream: %v", err)
}
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("first WebSocket stream did not start")
}
if _, err := session.Stream(t.Context(), request); !errors.Is(err, errCodexTurnSessionBusy) {
t.Fatalf("overlapping Stream error = %v, want %v", err, errCodexTurnSessionBusy)
}
releaseStream()
for range first {
}
}
func TestCodexTurnSessionHTTPFallbackRedactsSetupError(t *testing.T) {
const secret = "sk-secret-fallback"
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: secret,
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
session := &codexTurnSession{provider: provider}
events := make(chan zeroruntime.StreamEvent, 1)
session.forwardHTTP(t.Context(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRole(secret), Content: "invalid"}},
}, events)
select {
case event := <-events:
if event.Type != zeroruntime.StreamEventError {
t.Fatalf("fallback event type = %s, want error", event.Type)
}
if strings.Contains(event.Error, secret) {
t.Fatalf("fallback error leaked credential: %q", event.Error)
}
case <-time.After(time.Second):
t.Fatal("fallback did not emit setup error")
}
}
func TestCodexTurnSessionCloseDoesNotStartPrewarm(t *testing.T) {
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
session, err := NewCodexTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{}).OpenTurnSession(t.Context())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
done := make(chan error, 1)
go func() { done <- session.Close() }()
select {
case err := <-done:
if err != nil {
t.Fatalf("Close() error = %v", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("Close blocked without a prewarm attempt")
}
}
func newCodexSessionTestProvider(t *testing.T, server *httptest.Server) *CodexProvider {
t.Helper()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{
APIKey: "test-token",
BaseURL: server.URL,
Model: "gpt-test",
HTTPClient: server.Client(),
},
AccountID: "account-test",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
return provider
}
func openCodexSession(t *testing.T, provider *CodexProvider) zeroruntime.TurnSession {
t.Helper()
session, err := NewCodexTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{}).OpenTurnSession(t.Context())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if err := session.Prewarm(t.Context()); err != nil {
t.Fatalf("Prewarm: %v", err)
}
return session
}
func collectCodexSessionEvents(t *testing.T, session zeroruntime.TurnSession, request zeroruntime.CompletionRequest) []zeroruntime.StreamEvent {
t.Helper()
return collectCodexSessionEventsContext(t, t.Context(), session, request)
}
func collectCodexSessionEventsContext(t *testing.T, ctx context.Context, session zeroruntime.TurnSession, request zeroruntime.CompletionRequest) []zeroruntime.StreamEvent {
t.Helper()
stream, err := session.Stream(ctx, request)
if err != nil {
t.Fatalf("Stream: %v", err)
}
events := []zeroruntime.StreamEvent{}
for event := range stream {
events = append(events, event)
if event.Type == zeroruntime.StreamEventError {
t.Fatalf("stream error: %s", event.Error)
}
}
return events
}
func writeWebSocketEvents(ctx context.Context, connection *websocket.Conn, events ...string) {
for _, event := range events {
if err := connection.Write(ctx, websocket.MessageText, []byte(event)); err != nil {
return
}
}
}
func hasToolCallEnd(events []zeroruntime.StreamEvent, id string) bool {
for _, event := range events {
if event.Type == zeroruntime.StreamEventToolCallEnd && event.ToolCallID == id {
return true
}
}
return false
}
func joinedText(events []zeroruntime.StreamEvent) string {
var builder strings.Builder
for _, event := range events {
if event.Type == zeroruntime.StreamEventText {
builder.WriteString(event.Content)
}
}
return builder.String()
}
@@ -2,6 +2,8 @@ package openai
import (
"context"
"reflect"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -25,6 +27,126 @@ func TestToolCallKeyOutputIndexZero(t *testing.T) {
}
}
func TestResponsesStatePreservesDistinctCallID(t *testing.T) {
provider := &CodexProvider{}
state := newResponsesState()
events := make(chan zeroruntime.StreamEvent, 4)
provider.handleOutputItemAdded(context.Background(), &responsesEvent{
ItemID: "item-1",
Item: &itemPayload{
Type: "function_call",
ID: "item-1",
CallID: "call-1",
Name: "read_file",
},
}, state, events)
items := state.outputInputItems()
if len(items) != 1 || items[0].ID != "item-1" || items[0].CallID != "call-1" {
t.Fatalf("captured function call = %#v, want item id item-1 and call id call-1", items)
}
}
func TestCodexCustomApplyPatchStreamProducesFreeformCall(t *testing.T) {
provider := &CodexProvider{}
state := newResponsesState()
events := make(chan zeroruntime.StreamEvent, 16)
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
for _, payload := range []string{
`{"type":"response.output_item.added","item_id":"item-1","item":{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch"}}`,
`{"type":"response.custom_tool_call_input.delta","item_id":"item-1","call_id":"call-1","delta":"*** Begin Patch\n*** Add File: hello.txt\n"}`,
`{"type":"response.custom_tool_call_input.delta","item_id":"item-1","call_id":"call-1","delta":"+hello\n*** End Patch\n"}`,
`{"type":"response.output_item.done","item_id":"item-1","item":{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch","input":"*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
} {
if keepReading := provider.emitResponsesEvent(context.Background(), payload, state, events); !keepReading && !state.done {
t.Fatalf("stream stopped before terminal event for %s", payload)
}
}
close(events)
collected := zeroruntime.CollectStream(context.Background(), events)
if len(collected.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one", collected.ToolCalls)
}
call := collected.ToolCalls[0]
if call.ID != "item-1" || call.ProviderCallID != "call-1" || call.Name != "apply_patch" || !call.Freeform || call.Arguments != patch {
t.Fatalf("custom apply_patch call = %#v", call)
}
}
func TestCodexApplyPatchUsesFreeformWireContract(t *testing.T) {
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
patch := "*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch\n"
request, err := provider.buildResponsesRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "Add the file."},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{
ID: "item-1", ProviderCallID: "call-1", Name: "apply_patch", Arguments: patch, Freeform: true,
}}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "item-1", ToolCallProviderID: "call-1", Content: "Done!"},
},
Tools: []zeroruntime.ToolDefinition{{
Name: "apply_patch", Description: "Apply a patch.", Type: zeroruntime.ToolDefinitionFreeform,
}},
})
if err != nil {
t.Fatalf("buildResponsesRequest: %v", err)
}
if len(request.Tools) != 1 || request.Tools[0].Type != string(zeroruntime.ToolDefinitionFreeform) ||
request.Tools[0].Format == nil || request.Tools[0].Format.Syntax != "lark" || request.Tools[0].Parameters != nil {
t.Fatalf("apply_patch tool wire shape = %#v", request.Tools)
}
if !strings.Contains(request.Tools[0].Format.Definition, "*** Begin Patch") {
t.Fatalf("apply_patch grammar = %q", request.Tools[0].Format.Definition)
}
if !strings.Contains(request.Tools[0].Description, "absolute file paths") || !strings.Contains(request.Tools[0].Description, "granted extra write root") {
t.Fatalf("apply_patch description does not explain extra-root paths: %q", request.Tools[0].Description)
}
if len(request.Input) != 3 || request.Input[1].Type != "custom_tool_call" || request.Input[1].ID != "item-1" ||
request.Input[1].CallID != "call-1" || request.Input[1].Input != patch ||
request.Input[2].Type != "custom_tool_call_output" || request.Input[2].CallID != "call-1" {
t.Fatalf("custom apply_patch replay input = %#v", request.Input)
}
}
func TestCodexPreservesCustomApplyPatchFunction(t *testing.T) {
provider, err := NewCodexProvider(CodexOptions{Options: Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: "gpt-test",
}})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
parameters := map[string]any{
"type": "object",
"properties": map[string]any{"value": map[string]any{"type": "string"}},
}
request, err := provider.buildResponsesRequest(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "Call the custom tool."}},
Tools: []zeroruntime.ToolDefinition{{
Name: "apply_patch", Description: "Custom integration tool.", Parameters: parameters,
}},
})
if err != nil {
t.Fatalf("buildResponsesRequest: %v", err)
}
if len(request.Tools) != 1 {
t.Fatalf("tools = %#v, want one", request.Tools)
}
tool := request.Tools[0]
if tool.Type != string(zeroruntime.ToolDefinitionFunction) || tool.Format != nil ||
tool.Description != "Custom integration tool." || !reflect.DeepEqual(tool.Parameters, parameters) {
t.Fatalf("custom apply_patch function was rewritten: %#v", tool)
}
}
func TestHandleTerminalResponseNilPayload(t *testing.T) {
p := &CodexProvider{}
@@ -90,11 +212,14 @@ func TestHandleTerminalResponseFailedPayloadWithoutError(t *testing.T) {
// A response.completed with a payload and no error remains a clean done.
ok := make(chan zeroruntime.StreamEvent, 8)
p.handleTerminalResponse(context.Background(),
&responsesEvent{Type: responsesEventCompleted, Response: &responsePayload{Status: "completed"}}, &responsesState{}, ok)
&responsesEvent{Type: responsesEventCompleted, Response: &responsePayload{ID: "resp-1", Status: "completed"}}, &responsesState{}, ok)
close(ok)
for ev := range ok {
if ev.Type == zeroruntime.StreamEventError {
t.Error("response.completed with a non-error payload must NOT emit an error")
}
if ev.Type == zeroruntime.StreamEventDone && ev.ResponseID != "resp-1" {
t.Errorf("done response id = %q, want resp-1", ev.ResponseID)
}
}
}
+3 -3
View File
@@ -771,7 +771,7 @@ func TestCodexProviderParsesResponsesTextDeltas(t *testing.T) {
`{"type":"response.output_text.delta","delta":"hello "}`,
`{"type":"response.output_text.delta","delta":"world"}`,
`{"type":"response.output_text.delta","delta":"!"}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":7,"output_tokens":3,"output_tokens_details":{"reasoning_tokens":1},"input_tokens_details":{"cached_tokens":2}}}}`,
`{"type":"response.completed","response":{"id":"resp-1","status":"completed","usage":{"input_tokens":7,"output_tokens":3,"output_tokens_details":{"reasoning_tokens":1},"input_tokens_details":{"cached_tokens":2,"cache_write_tokens":1}}}}`,
)
defer srv.Close()
@@ -809,8 +809,8 @@ func TestCodexProviderParsesResponsesTextDeltas(t *testing.T) {
if usage == nil {
t.Fatal("expected a usage event, got none")
}
if usage.InputTokens != 7 || usage.OutputTokens != 3 || usage.CachedInputTokens != 2 || usage.ReasoningTokens != 1 {
t.Fatalf("usage = %+v, want input=7 output=3 cached=2 reasoning=1", *usage)
if usage.InputTokens != 7 || usage.OutputTokens != 3 || usage.CachedInputTokens != 2 || usage.CacheWriteTokens != 1 || usage.ReasoningTokens != 1 {
t.Fatalf("usage = %+v, want input=7 output=3 cached=2 cache-write=1 reasoning=1", *usage)
}
if !gotDone {
t.Fatal("expected a done event, got none")
+1
View File
@@ -357,6 +357,7 @@ func (provider *Provider) emitChunk(
PromptTokens: chunk.Usage.PromptTokens,
CompletionTokens: chunk.Usage.CompletionTokens,
CachedInputTokens: chunk.Usage.PromptTokensDetails.CachedTokens,
CacheWriteTokens: chunk.Usage.PromptTokensDetails.CacheWriteTokens,
ReasoningTokens: chunk.Usage.CompletionTokensDetails.ReasoningTokens,
},
})
+2 -2
View File
@@ -260,14 +260,14 @@ func TestStreamCompletionAppliesCustomAuthAndHeaders(t *testing.T) {
func TestStreamCompletionEmitsTextUsageAndDone(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"hello "}}]}`)
writeSSE(w, `{"choices":[{"delta":{"content":"zero"}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":3}}}`)
writeSSE(w, `{"choices":[{"delta":{"content":"zero"}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":3,"cache_write_tokens":2}}}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
assertEvent(t, events[0], zeroruntime.StreamEventText, "hello ")
assertEvent(t, events[1], zeroruntime.StreamEventText, "zero")
if events[2].Type != zeroruntime.StreamEventUsage || events[2].Usage.PromptTokens != 12 || events[2].Usage.CompletionTokens != 5 || events[2].Usage.CachedInputTokens != 3 {
if events[2].Type != zeroruntime.StreamEventUsage || events[2].Usage.PromptTokens != 12 || events[2].Usage.CompletionTokens != 5 || events[2].Usage.CachedInputTokens != 3 || events[2].Usage.CacheWriteTokens != 2 {
t.Fatalf("unexpected usage event: %#v", events[2])
}
if events[3].Type != zeroruntime.StreamEventDone {
+6 -1
View File
@@ -195,7 +195,12 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
func wireToolsDigest(tools []zeroruntime.ToolDefinition) string {
rendered := make([]string, 0, len(tools))
for _, tool := range tools {
schema, err := json.Marshal(tool.Parameters)
wireShape := struct {
Type zeroruntime.ToolDefinitionType `json:"type,omitempty"`
Parameters map[string]any `json:"parameters,omitempty"`
Format *zeroruntime.ToolDefinitionFormat `json:"format,omitempty"`
}{Type: tool.Type, Parameters: tool.Parameters, Format: tool.Format}
schema, err := json.Marshal(wireShape)
if err != nil {
rendered = append(rendered, tool.Name+"\n"+tool.Description+"\n__non_json:"+tool.Name)
continue
+2 -1
View File
@@ -109,7 +109,8 @@ type usage struct {
}
type promptTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
CachedTokens int `json:"cached_tokens"`
CacheWriteTokens int `json:"cache_write_tokens"`
}
type completionTokenDetails struct {
+43 -12
View File
@@ -15,9 +15,27 @@ import (
// value (e.g. "1") to enable — the same boolean idiom as ZERO_FORMAT_ON_WRITE.
const openaiTurnSessionEnv = "ZERO_OPENAI_TURN_SESSION"
// ChatGPT Responses sessions are enabled by default for their native provider.
// Setting this to 0/false/off restores the stateless HTTP/SSE transport.
const chatGPTTurnSessionEnv = "ZERO_CHATGPT_TURN_SESSION"
func openaiTurnSessionEnabled() bool {
value := strings.TrimSpace(os.Getenv(openaiTurnSessionEnv))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
return value != "" && !explicitEnvFalse(value)
}
func chatGPTTurnSessionEnabled() bool {
value := strings.TrimSpace(os.Getenv(chatGPTTurnSessionEnv))
return value == "" || !explicitEnvFalse(value)
}
func explicitEnvFalse(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "0", "false", "off":
return true
default:
return false
}
}
// OptimizedTurnSessions returns the gated optimized TurnSessionProvider for an
@@ -25,20 +43,33 @@ func openaiTurnSessionEnabled() bool {
// is ineligible. Callers leave agent Options.TurnSessionProvider nil on false,
// so the loop's default wrap keeps today's behavior byte-identical.
//
// Eligibility is deliberately narrow: the resolved provider kind must be
// official OpenAI (NOT openai-compatible gateways — the constructor merges the
// two, so this branches on the resolved kind explicitly), not the ChatGPT
// Codex catalog, and the provider value must be the concrete *openai.Provider
// (a fake, a Codex provider, or nil falls back to the default path safely).
// No base-URL check on top of the kind: prewarm and fingerprint telemetry are
// harmless against any host, and kind==openai mirrors the existing
// official-OpenAI precedent used for prompt_cache_key.
// Eligibility is deliberately narrow: native ChatGPT Responses uses its
// concrete Codex provider and defaults on with a kill switch; official OpenAI
// chat-completions keeps its existing opt-in prewarm session. Compatible
// gateways and foreign provider values always retain the default adapter.
func OptimizedTurnSessions(profile config.ProviderProfile, provider zeroruntime.Provider, options Options) (zeroruntime.TurnSessionProvider, bool) {
if !openaiTurnSessionEnabled() {
resolved, err := resolveProfile(profile, options)
if err != nil {
return nil, false
}
resolved, err := resolveProfile(profile, options)
if err != nil || resolved.providerKind != config.ProviderKindOpenAI || isCodexCatalog(profile, resolved) {
if isCodexCatalog(profile, resolved) {
if !chatGPTTurnSessionEnabled() {
return nil, false
}
concrete, ok := provider.(*openai.CodexProvider)
if !ok {
return nil, false
}
caps, err := resolveCapabilities(profile, options)
if err != nil {
return nil, false
}
return openai.NewCodexTurnSessionProvider(concrete, caps), true
}
if resolved.providerKind != config.ProviderKindOpenAI {
return nil, false
}
if !openaiTurnSessionEnabled() {
return nil, false
}
concrete, ok := provider.(*openai.Provider)
+72 -5
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -45,7 +46,7 @@ func TestOptimizedTurnSessionsDefaultOff(t *testing.T) {
func TestOptimizedTurnSessionsFalseyValues(t *testing.T) {
profile := openaiEligibleProfile()
provider := buildGateProvider(t, profile)
for _, value := range []string{"0", "false", "FALSE", "False", " ", ""} {
for _, value := range []string{"0", "false", "FALSE", "False", "off", "OFF", " ", ""} {
t.Setenv(openaiTurnSessionEnv, value)
if _, ok := OptimizedTurnSessions(profile, provider, Options{}); ok {
t.Fatalf("gate enabled for falsey value %q", value)
@@ -59,6 +60,21 @@ func TestOptimizedTurnSessionsFalseyValues(t *testing.T) {
}
}
func TestChatGPTTurnSessionGateValues(t *testing.T) {
for _, value := range []string{"0", "false", "FALSE", "off", "OFF"} {
t.Setenv(chatGPTTurnSessionEnv, value)
if chatGPTTurnSessionEnabled() {
t.Fatalf("ChatGPT gate enabled for falsey value %q", value)
}
}
for _, value := range []string{"", " ", "1", "true", "on"} {
t.Setenv(chatGPTTurnSessionEnv, value)
if !chatGPTTurnSessionEnabled() {
t.Fatalf("ChatGPT gate disabled for default/truthy value %q", value)
}
}
}
func TestOptimizedTurnSessionsOpenAIEligible(t *testing.T) {
t.Setenv(openaiTurnSessionEnv, "1")
profile := openaiEligibleProfile()
@@ -88,13 +104,64 @@ func TestOptimizedTurnSessionsRejectsCompatible(t *testing.T) {
}
func TestOptimizedTurnSessionsRejectsCodexCatalog(t *testing.T) {
t.Setenv(openaiTurnSessionEnv, "1")
t.Setenv(chatGPTTurnSessionEnv, "0")
profile := openaiEligibleProfile()
profile.Name = "chatgpt"
profile.CatalogID = "chatgpt"
if _, ok := OptimizedTurnSessions(profile, buildChatGPTGateProvider(t, profile), Options{}); ok {
t.Fatal("the ChatGPT Codex catalog must honor the turn-session kill switch")
}
}
func TestOptimizedTurnSessionsEnablesChatGPTResponsesByDefault(t *testing.T) {
t.Setenv(chatGPTTurnSessionEnv, "")
profile := openaiEligibleProfile()
profile.Name = "chatgpt"
profile.CatalogID = "chatgpt"
provider := buildChatGPTGateProvider(t, profile)
turnSessions, ok := OptimizedTurnSessions(profile, provider, Options{})
if !ok || turnSessions == nil {
t.Fatal("ChatGPT Responses provider did not receive its default turn session")
}
if got := turnSessions.Capabilities().Model; got != profile.Model {
t.Fatalf("Capabilities().Model = %q, want %q", got, profile.Model)
}
}
func TestOptimizedTurnSessionsRecognizesCompatibleLabeledChatGPTProfile(t *testing.T) {
t.Setenv(chatGPTTurnSessionEnv, "")
profile := openaiEligibleProfile()
profile.Name = "chatgpt"
profile.CatalogID = "chatgpt"
profile.ProviderKind = config.ProviderKindOpenAICompatible
if turnSessions, ok := OptimizedTurnSessions(profile, buildChatGPTGateProvider(t, profile), Options{}); !ok || turnSessions == nil {
t.Fatal("ChatGPT catalog profile must use its native session regardless of the stored compatibility label")
}
}
func buildChatGPTGateProvider(t *testing.T, profile config.ProviderProfile) *openai.CodexProvider {
t.Helper()
provider, err := openai.NewCodexProvider(openai.CodexOptions{
Options: openai.Options{
APIKey: "test-token",
BaseURL: "https://chatgpt.example/backend-api/codex",
Model: profile.Model,
},
AccountID: "account-test",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
return provider
}
func TestOptimizedTurnSessionsRejectsNonCodexProviderForChatGPT(t *testing.T) {
t.Setenv(chatGPTTurnSessionEnv, "1")
profile := openaiEligibleProfile()
profile.CatalogID = "chatgpt"
// Build the provider from the base profile: the point under test is the
// catalog check, not Codex construction (which needs OAuth wiring).
if _, ok := OptimizedTurnSessions(profile, buildGateProvider(t, openaiEligibleProfile()), Options{}); ok {
t.Fatal("the ChatGPT Codex catalog must not get the optimized session")
t.Fatal("a ChatGPT catalog profile with a non-Codex provider must use the default session")
}
}
+2
View File
@@ -94,6 +94,8 @@ type Event struct {
Meta map[string]string `json:"meta,omitempty"`
PromptTokens *int `json:"promptTokens,omitempty"`
CompletionTokens *int `json:"completionTokens,omitempty"`
CachedInputTokens *int `json:"cachedInputTokens,omitempty"`
CacheWriteTokens *int `json:"cacheWriteTokens,omitempty"`
TotalTokens *int `json:"totalTokens,omitempty"`
CostUSD *float64 `json:"costUsd,omitempty"`
Text string `json:"text,omitempty"`
+95
View File
@@ -16,6 +16,101 @@ type applyPatchTool struct {
scope PathScope
}
func (applyPatchTool) isBuiltInApplyPatch() {}
// PrepareFreeformApplyPatchArguments converts native structured-patch input
// into the ordinary apply_patch argument map used by permission checks and tool
// execution. Absolute patch-header paths select a granted write root and are
// rewritten relative to that root; relative-only patches keep workspace-root
// behavior.
func PrepareFreeformApplyPatchArguments(tool Tool, patch string) (map[string]any, error) {
preparer, ok := tool.(interface {
prepareFreeformArguments(string) (map[string]any, error)
})
if !ok {
return nil, fmt.Errorf("tool is not Zero's built-in apply_patch")
}
return preparer.prepareFreeformArguments(patch)
}
func (tool applyPatchTool) prepareFreeformArguments(patch string) (map[string]any, error) {
args := map[string]any{"patch": patch}
if !isStructuredPatch(patch) {
return args, nil
}
lines := strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n")
selectedRoot := ""
hasRelativeHeader := false
for index, line := range lines {
prefix := ""
switch {
case strings.HasPrefix(line, structuredAddFile):
prefix = structuredAddFile
case strings.HasPrefix(line, structuredDeleteFile):
prefix = structuredDeleteFile
case strings.HasPrefix(line, structuredUpdateFile):
prefix = structuredUpdateFile
case strings.HasPrefix(line, structuredMoveTo):
prefix = structuredMoveTo
default:
continue
}
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if !filepath.IsAbs(path) {
hasRelativeHeader = true
continue
}
root, relative, err := tool.freeformPatchRoot(path)
if err != nil {
return nil, err
}
if selectedRoot != "" && selectedRoot != root {
return nil, fmt.Errorf("freeform patch spans multiple write roots")
}
selectedRoot = root
lines[index] = prefix + filepath.ToSlash(relative)
}
if selectedRoot != "" {
roots, err := scopedRoots(tool.workspaceRoot, tool.scope)
if err != nil {
return nil, err
}
if hasRelativeHeader && selectedRoot != roots[0] {
return nil, fmt.Errorf("freeform patch mixes workspace-relative paths with an extra write root")
}
args["cwd"] = selectedRoot
args["patch"] = strings.Join(lines, "\n")
}
return args, nil
}
func (tool applyPatchTool) freeformPatchRoot(path string) (string, string, error) {
roots, err := scopedRoots(tool.workspaceRoot, tool.scope)
if err != nil {
return "", "", err
}
var firstErr error
for _, root := range roots {
candidate := sandbox.NormalizePrefixForRoot(path, root)
candidate, err = filepath.Abs(candidate)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
relative, err := filepath.Rel(root, candidate)
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative) {
return root, filepath.ToSlash(relative), nil
}
if firstErr == nil {
firstErr = outsideWorkspaceError(path)
}
}
return "", "", firstErr
}
func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
+3
View File
@@ -9,6 +9,9 @@ import (
func TestUpdatePlanToolStoresAndFormatsPlan(t *testing.T) {
tool := NewUpdatePlanTool()
if description := strings.ToLower(tool.Description()); !strings.Contains(description, "skip it for bounded changes in one component") {
t.Fatalf("update_plan description must preserve the bounded-task fast path: %q", description)
}
result := tool.Run(context.Background(), map[string]any{
"plan": []any{
+1 -1
View File
@@ -33,7 +33,7 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
return readFileTool{
baseTool: baseTool{
name: "read_file",
description: "Read exact file text, each line prefixed with its line number and →. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
description: "Read exact file text, each line prefixed with its line number and →. Use directly for small files likely to be edited, or when comments, formatting, or line numbers matter. Prefer read_minified_file only for exploratory understanding of large or unfamiliar code. Use offset and limit for a line range.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
+1 -1
View File
@@ -28,7 +28,7 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
return readMinifiedFileTool{
baseTool: baseTool{
name: "read_minified_file",
description: "Read source code in a token-efficient, language-aware form. Prefer this for initial understanding; use read_file for exact text or line numbers.",
description: "Read source code in a token-efficient, language-aware form. Use for exploratory understanding of large or unfamiliar source when no exact edit is planned; use read_file directly for likely edit targets. Do not immediately reread the same file exactly unless a new need for exact text or line numbers appears.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
+23
View File
@@ -8,6 +8,29 @@ import (
"testing"
)
func TestReadToolsDescribeExploratoryAndExactUseWithoutRedundantRereads(t *testing.T) {
root := t.TempDir()
minified := strings.ToLower(NewScopedReadMinifiedFileTool(root, nil).Description())
for _, want := range []string{
"exploratory understanding",
"use read_file directly for likely edit targets",
"unless a new need for exact text or line numbers appears",
} {
if !strings.Contains(minified, want) {
t.Fatalf("read_minified_file description missing %q: %s", want, minified)
}
}
exact := strings.ToLower(NewScopedReadFileTool(root, nil).Description())
for _, want := range []string{
"use directly for small files likely to be edited",
"prefer read_minified_file only for exploratory understanding",
} {
if !strings.Contains(exact, want) {
t.Fatalf("read_file description missing %q: %s", want, exact)
}
}
}
func TestReadMinifiedFileStripsCommentsAndLineNumbers(t *testing.T) {
dir := t.TempDir()
src := "package demo\n\nimport \"fmt\"\n\n// secret doc comment\nfunc F() { fmt.Println(\"x\") }\n"
+59 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"sort"
"strings"
"sync"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -12,7 +13,17 @@ import (
)
type Registry struct {
tools map[string]Tool
mu sync.RWMutex
tools map[string]Tool
generation uint64
}
// RegistrySnapshot is one immutable registry generation. Tools is a detached,
// name-sorted slice: later registrations cannot change the snapshot observed by
// an in-flight agent turn.
type RegistrySnapshot struct {
Tools []Tool
Generation uint64
}
type RunOptions struct {
@@ -107,23 +118,69 @@ func IsDeferralEligible(t Tool) bool {
}
func (registry *Registry) Register(tool Tool) {
registry.mu.Lock()
defer registry.mu.Unlock()
registry.tools[tool.Name()] = tool
registry.generation++
}
// RegisterBatch publishes a group of tools as one registry generation. Readers
// observe either the complete prior generation or the complete new one.
func (registry *Registry) RegisterBatch(batch []Tool) {
if len(batch) == 0 {
return
}
registry.mu.Lock()
defer registry.mu.Unlock()
for _, tool := range batch {
registry.tools[tool.Name()] = tool
}
registry.generation++
}
func (registry *Registry) Get(name string) (Tool, bool) {
registry.mu.RLock()
defer registry.mu.RUnlock()
tool, ok := registry.tools[name]
return tool, ok
}
func (registry *Registry) All() []Tool {
return registry.Snapshot().Tools
}
// Snapshot returns a stable, sorted view of the registry at one generation.
func (registry *Registry) Snapshot() RegistrySnapshot {
registry.mu.RLock()
tools := make([]Tool, 0, len(registry.tools))
for _, tool := range registry.tools {
tools = append(tools, tool)
}
generation := registry.generation
registry.mu.RUnlock()
sort.Slice(tools, func(left, right int) bool {
return tools[left].Name() < tools[right].Name()
})
return tools
return RegistrySnapshot{Tools: tools, Generation: generation}
}
// Clone returns an independent registry containing exactly one source snapshot.
func (registry *Registry) Clone() *Registry {
clone := NewRegistry()
if registry == nil {
return clone
}
snapshot := registry.Snapshot()
clonedTools := make([]Tool, 0, len(snapshot.Tools))
for _, tool := range snapshot.Tools {
if cloner, ok := tool.(interface{ cloneForRegistry(*Registry) Tool }); ok {
tool = cloner.cloneForRegistry(clone)
}
clonedTools = append(clonedTools, tool)
}
clone.RegisterBatch(clonedTools)
clone.generation = snapshot.Generation
return clone
}
func (registry *Registry) Run(ctx context.Context, name string, args map[string]any) Result {
+77
View File
@@ -2,10 +2,12 @@ package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
@@ -95,6 +97,81 @@ func TestRegistryAllReturnsToolsSortedByName(t *testing.T) {
}
}
func TestRegistrySnapshotAndCloneStayOnOneGeneration(t *testing.T) {
registry := NewRegistry()
registry.Register(fakePlainTool{baseTool: baseTool{name: "alpha", description: "alpha"}})
first := registry.Snapshot()
registry.RegisterBatch([]Tool{
fakePlainTool{baseTool: baseTool{name: "charlie", description: "charlie"}},
fakePlainTool{baseTool: baseTool{name: "bravo", description: "bravo"}},
})
second := registry.Snapshot()
clone := registry.Clone()
registry.Register(fakePlainTool{baseTool: baseTool{name: "delta", description: "delta"}})
if got := toolNames(first.Tools); !slices.Equal(got, []string{"alpha"}) {
t.Fatalf("first snapshot changed after registration: %v", got)
}
if second.Generation != first.Generation+1 {
t.Fatalf("batch generation = %d, want %d", second.Generation, first.Generation+1)
}
if got := toolNames(second.Tools); !slices.Equal(got, []string{"alpha", "bravo", "charlie"}) {
t.Fatalf("second snapshot tools = %v", got)
}
if got := toolNames(clone.All()); !slices.Equal(got, toolNames(second.Tools)) {
t.Fatalf("clone changed with source registry: %v", got)
}
if clone.Snapshot().Generation != second.Generation {
t.Fatalf("clone generation = %d, want %d", clone.Snapshot().Generation, second.Generation)
}
}
func TestRegistryBatchIsAtomicForConcurrentReaders(t *testing.T) {
registry := NewRegistry()
registry.Register(fakePlainTool{baseTool: baseTool{name: "base", description: "base"}})
batch := make([]Tool, 128)
for index := range batch {
name := fmt.Sprintf("tool_%03d", index)
batch[index] = fakePlainTool{baseTool: baseTool{name: name, description: name}}
}
start := make(chan struct{})
done := make(chan struct{})
var readers sync.WaitGroup
for range 4 {
readers.Add(1)
go func() {
defer readers.Done()
<-start
for {
count := len(registry.Snapshot().Tools)
if count != 1 && count != len(batch)+1 {
t.Errorf("reader observed partial batch with %d tools", count)
return
}
select {
case <-done:
return
default:
}
}
}()
}
close(start)
registry.RegisterBatch(batch)
close(done)
readers.Wait()
}
func toolNames(toolset []Tool) []string {
names := make([]string, 0, len(toolset))
for _, tool := range toolset {
names = append(names, tool.Name())
}
return names
}
func TestCoreNetworkToolsExposeSafetyMetadata(t *testing.T) {
// web_search is only registered when a backend is configured.
t.Setenv("ZERO_WEBSEARCH_BASE_URL", "https://search.example/api")
+5
View File
@@ -58,6 +58,11 @@ func NewToolSearchTool(registry *Registry) Tool {
}
}
func (tool toolSearchTool) cloneForRegistry(registry *Registry) Tool {
tool.registry = registry
return tool
}
// 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{})
+31
View File
@@ -73,6 +73,37 @@ func newDeferredFixtureRegistry() *Registry {
return reg
}
func TestRegistryCloneKeepsToolSearchOnFrozenSnapshot(t *testing.T) {
live := newDeferredFixtureRegistry()
live.Register(NewToolSearchTool(live))
runRegistry := live.Clone()
live.Register(searchFakeTool{
name: "late_lookup",
description: "Registered after the run snapshot.",
parameters: Schema{Type: "object", AdditionalProperties: false},
})
result := runRegistry.Run(context.Background(), ToolSearchToolName, map[string]any{
"query": "select:late_lookup",
})
if result.Status != StatusOK {
t.Fatalf("tool_search status = %s: %s", result.Status, result.Output)
}
if loaded := result.Meta["load_tools"]; loaded != "" {
t.Fatalf("snapshot tool_search loaded late tool %q", loaded)
}
if direct := runRegistry.Run(context.Background(), "late_lookup", map[string]any{}); direct.Status != StatusError {
t.Fatalf("late tool unexpectedly executable in run snapshot: %#v", direct)
}
known := runRegistry.Run(context.Background(), ToolSearchToolName, map[string]any{
"query": "select:weather_lookup",
})
if loaded := known.Meta["load_tools"]; loaded != "weather_lookup" {
t.Fatalf("snapshot tool_search lost existing tool: load_tools=%q output=%q", loaded, known.Output)
}
}
func TestToolSearchExposesExpectedSafetyAndSchema(t *testing.T) {
tool := NewToolSearchTool(NewRegistry())
+8
View File
@@ -211,6 +211,14 @@ type Tool interface {
Run(ctx context.Context, args map[string]any) Result
}
// IsBuiltInApplyPatch reports whether tool is Zero's scoped patch tool. The
// marker method is package-private so external and MCP tools cannot claim this
// identity merely by sharing the public name "apply_patch".
func IsBuiltInApplyPatch(tool Tool) bool {
_, ok := tool.(interface{ isBuiltInApplyPatch() })
return ok
}
// ArgsPermissioner is an optional interface a Tool can implement to refine its
// permission for a SPECIFIC call based on its arguments. When a tool implements
// it, the agent loop consults PermissionForArgs(args) instead of the static
+1 -1
View File
@@ -26,7 +26,7 @@ func NewUpdatePlanTool() *updatePlanTool {
return &updatePlanTool{
baseTool: baseTool{
name: "update_plan",
description: "Create or update the in-memory plan for implementation or investigation with at least three meaningful dependent steps. Do not use it for simple lookups, explanations, or code navigation. " +
description: "Create or update the in-memory plan for work spanning multiple components or workstreams, sequencing uncertainty, or sustained tracking across many tool calls. Skip it for bounded changes in one component, simple lookups, explanations, and code navigation. " +
"Pass the full ordered list of steps each call; it replaces the previous plan. " +
"Each item needs a `content` string; `status` defaults to \"pending\" and `id` is " +
"auto-numbered, so you only need to supply `content` (and `status` as the task progresses). " +
+11
View File
@@ -53,6 +53,7 @@ const (
CounterModelSwitches = "model_switches"
CounterInputTokens = "input_tokens"
CounterCachedInputTokens = "cached_input_tokens"
CounterCacheWriteTokens = "cache_write_tokens"
CounterOutputTokens = "output_tokens"
CounterPrewarmAttempts = "prewarm_attempts"
CounterPrefixStable = "prefix_stable"
@@ -64,6 +65,12 @@ const (
CounterPostureEscalations = "posture_escalations"
)
const (
CounterResponseChainReused = "response_chain_reused"
CounterResponseChainReset = "response_chain_reset"
CounterResponsesHTTPFallback = "responses_http_fallback"
)
// Span is one named wall interval attributed to part of a run. Each stamp is
// its own entry (spans are not merged by name): a run that streams two model
// completions has two generation entries, which lets the recorder derive
@@ -290,6 +297,7 @@ func OptionalEventKeys() []string {
"span:" + SpanVerification,
"counter:" + CounterToolCalls,
"counter:" + CounterCachedInputTokens,
"counter:" + CounterCacheWriteTokens,
"counter:" + CounterRetryCount,
"counter:" + CounterReconnectCount,
"counter:" + CounterCompactionCount,
@@ -301,6 +309,9 @@ func OptionalEventKeys() []string {
"counter:" + CounterPrewarmAttempts,
"counter:" + CounterPrefixStable,
"counter:" + CounterPrefixDrift,
"counter:" + CounterResponseChainReused,
"counter:" + CounterResponseChainReset,
"counter:" + CounterResponsesHTTPFallback,
"counter:" + CounterPostureEscalations,
"event:prefix_hash",
"task_state",
+14 -4
View File
@@ -614,11 +614,21 @@ func TestCounterPrecisionRoundTrip(t *testing.T) {
}
}
func TestOptionalEventKeysIncludePostureEscalations(t *testing.T) {
func TestOptionalEventKeysIncludeRuntimeCounters(t *testing.T) {
keys := make(map[string]bool)
for _, key := range OptionalEventKeys() {
if key == "counter:"+CounterPostureEscalations {
return
keys[key] = true
}
for _, counter := range []string{
CounterCacheWriteTokens,
CounterResponseChainReused,
CounterResponseChainReset,
CounterResponsesHTTPFallback,
CounterPostureEscalations,
} {
key := "counter:" + counter
if !keys[key] {
t.Errorf("%s missing from OptionalEventKeys: %v", key, OptionalEventKeys())
}
}
t.Fatalf("posture_escalations missing from OptionalEventKeys: %v", OptionalEventKeys())
}
+85 -40
View File
@@ -30,7 +30,6 @@ import (
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
@@ -86,10 +85,12 @@ type model struct {
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
newTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
registry *tools.Registry
awaitToolReadiness func(context.Context)
// lspManager is created once per session and reused across prompts so gopls (and
// other language servers) stay warm — a fresh manager per run would cold-start
// the server on the first edit of every turn. Nil when cwd is unknown; runs then
@@ -234,17 +235,20 @@ type model struct {
leaderHelpOverlay bool
// leaderPending is true after Ctrl+X until a second key, Esc, or timeout
// resolves the chord (see leader.go). leaderSeq invalidates a stale tick.
leaderPending bool
leaderSeq int
transcriptBodyHeights *transcriptBodyHeightCache
input textinput.Model
composer composerState
composerActive bool
composerCursorVisible bool
composerPastePreviews []composerPastePreview
composerSelection composerSelectionState
dictation dictationController
sttKeyPrompt *sttKeyPromptState
leaderPending bool
leaderSeq int
transcriptBodyHeights *transcriptBodyHeightCache
input textinput.Model
composer composerState
composerActive bool
composerCursorVisible bool
composerBlinkSeq int
composerBlinkIdleTicks int
composerBlinkTicking bool
composerPastePreviews []composerPastePreview
composerSelection composerSelectionState
dictation dictationController
sttKeyPrompt *sttKeyPromptState
// plan holds the sticky plan panel state (steps, expansion, timings)
// synced from the update_plan tool. See plan_panel.go.
plan planPanelState
@@ -492,10 +496,11 @@ type model struct {
// transientNotice is a single, replaceable confirmation shown above the
// composer. Unlike copyStatus it is shared by lightweight slash commands
// and is never persisted into the transcript.
transientNotice transientNotice
transientNoticeSeq int
exitConfirmActive bool
exitConfirmSeq int
transientNotice transientNotice
transientNoticeSeq int
transientNoticeTimerSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
@@ -542,7 +547,6 @@ type model struct {
recapIdleArmed bool
recapIdleRunID int
idleRecap string
compactionModel string // preferences.compactionModel (see providers.CompactionModelID)
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
@@ -929,6 +933,13 @@ func newModel(ctx context.Context, options Options) model {
// terminal bracketed paste (Paste: true) is the single paste path.
input.KeyMap.Paste.SetEnabled(false)
input.Focus()
// The main composer paints its own cursor through composerCursorVisible.
// Keep the embedded textinput cursor static so the shared textinput.Blink
// message cannot start a second, invisible 530ms tick chain. Setup inputs
// still use the normal Bubbles cursor and continue blinking independently.
inputStyles := input.Styles()
inputStyles.Cursor.Blink = false
input.SetStyles(inputStyles)
runSpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot))
runSpinner.Spinner.FPS = activeAnimationFrameInterval
@@ -953,6 +964,8 @@ func newModel(ctx context.Context, options Options) model {
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
composerBlinkSeq: 1,
composerBlinkTicking: true,
terminalFocused: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
@@ -965,14 +978,15 @@ func newModel(ctx context.Context, options Options) model {
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recentModels: normalizeRecentModelEntries(options.RecentModels),
compactionModel: options.CompactionModel,
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
newTurnSessionProvider: options.NewTurnSessionProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
registry: registry,
awaitToolReadiness: options.AwaitToolReadiness,
sessionStore: sessionStore,
peerService: options.PeerService,
sandboxStore: sandboxStore,
@@ -1093,6 +1107,11 @@ const (
// composerCursorBlinkInterval is the on/off period of the composer text cursor.
const composerCursorBlinkInterval = 530 * time.Millisecond
// composerBlinkIdleTicksBeforeSettle bounds the software cursor animation.
// Once the terminal is focused but otherwise idle, the cursor finishes a short
// blink sequence and settles visible instead of waking the TUI forever.
const composerBlinkIdleTicksBeforeSettle = 4
// composerTypingIdleThreshold is how long a typing pause must last before the
// cursor resumes blinking; comfortably above normal inter-keystroke gaps
// (~150-300ms) so it won't flicker mid-sentence.
@@ -1101,16 +1120,27 @@ const composerTypingIdleThreshold = 500 * time.Millisecond
// composerBlinkMsg toggles the composer cursor's visibility each tick. The custom
// composer render draws its own cursor (not textinput's), so it drives its own
// blink rather than relying on textinput.Blink.
type composerBlinkMsg struct{}
type composerBlinkMsg struct{ seq int }
func composerBlinkCmd() tea.Cmd {
func composerBlinkCmd(seq int) tea.Cmd {
return tea.Tick(composerCursorBlinkInterval, func(time.Time) tea.Msg {
return composerBlinkMsg{}
return composerBlinkMsg{seq: seq}
})
}
func (m model) armComposerBlink() (model, tea.Cmd) {
m.composerCursorVisible = true
m.composerBlinkIdleTicks = 0
if m.composerBlinkTicking {
return m, nil
}
m.composerBlinkTicking = true
m.composerBlinkSeq++
return m, composerBlinkCmd(m.composerBlinkSeq)
}
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd(m.composerBlinkSeq)}
if m.petAnimation != nil && !m.reducedMotion {
cmds = append(cmds, petTickCmd(m.petTickSeq, m.petFrameDelay()))
}
@@ -1293,7 +1323,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
nm = nm.syncChatScroll()
nm, mouseCmd := nm.syncMouseCapture()
nm, flushCmd := nm.settleTranscript()
return nm, batchCommands(cmd, mouseCmd, flushCmd, recapActivityCmd)
var composerCmd tea.Cmd
switch msg.(type) {
case tea.KeyPressMsg, tea.PasteMsg, tea.FocusMsg:
if nm.terminalFocused {
nm, composerCmd = nm.armComposerBlink()
}
}
nm, noticeCmd := nm.ensureTransientNoticeTimer()
return nm, batchCommands(cmd, mouseCmd, flushCmd, recapActivityCmd, composerCmd, noticeCmd)
}
func batchCommands(cmds ...tea.Cmd) tea.Cmd {
@@ -1356,16 +1394,27 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case composerBlinkMsg:
m = m.expireTransientNotice()
if !m.composerBlinkTicking || msg.seq != m.composerBlinkSeq {
return m, nil
}
switch {
case !m.terminalFocused:
m.composerCursorVisible = false // hidden while unfocused
m.composerCursorVisible = false
m.composerBlinkTicking = false
return m, nil
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
m.composerCursorVisible = true // solid while actively typing
m.composerCursorVisible = true
m.composerBlinkIdleTicks = 0
default:
m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before
m.composerBlinkIdleTicks++
if m.composerBlinkIdleTicks >= composerBlinkIdleTicksBeforeSettle {
m.composerCursorVisible = true
m.composerBlinkTicking = false
return m, nil
}
m.composerCursorVisible = !m.composerCursorVisible
}
return m, composerBlinkCmd()
return m, composerBlinkCmd(m.composerBlinkSeq)
case tea.BackgroundColorMsg:
// A background reading changes only the contrast direction used for named
// palettes. It never repaints the terminal canvas.
@@ -2192,6 +2241,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.terminalFocused = false
m.composerCursorVisible = false
m.composerBlinkTicking = false
m.composerBlinkSeq++
if m.notifier != nil {
m.notifier.SetFocused(false)
}
@@ -5375,6 +5426,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
sessionEvents := []pendingSessionEvent{}
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
if m.awaitToolReadiness != nil {
m.awaitToolReadiness(runCtx)
}
options := m.agentOptions
options.Registry = cloneToolRegistry(m.registry)
goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" &&
@@ -5417,6 +5471,9 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
options.ResponseStyle = m.responseStyle
options.Cwd = m.cwd
options.Images = images
if m.newTurnSessionProvider != nil && m.provider != nil {
options.TurnSessionProvider = m.newTurnSessionProvider(m.providerProfile, m.provider)
}
if m.captureRunImages != nil {
m.captureRunImages(images)
}
@@ -5425,18 +5482,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
// compaction (proactive + reactive) is enabled for every model, not just
// catalogued ones.
options.ContextWindow = modelregistry.AgentContextWindow(m.modelContextWindow(m.modelName))
// Route compaction summarization to a cheap model when one applies
// (env > config > curated default). The factory is lazy, re-evaluated
// against the model in force when the compactor asks (so an escalation
// away from the cheap model gains a summarizer), and the agent falls
// back to the main provider on any summarizer failure. Resolved against
// the ACTIVE profile so it tracks mid-session model switches.
options.Summarizer = providers.CompactionSummarizerFactory(m.providerProfile, m.compactionModel, m.newProvider)
// Let compaction re-derive its threshold after a mid-run escalate_model
// switch instead of keeping the original model's window.
options.ContextWindowFor = func(modelID string) int {
return modelregistry.AgentContextWindow(m.modelContextWindow(modelID))
}
// Post-edit self-correction is on by default in the TUI but kept FAST: it
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
+156 -11
View File
@@ -109,6 +109,81 @@ func TestPromptSubmitInjectsLiveSessionModelContext(t *testing.T) {
}
}
func TestPromptWaitsForToolReadinessBeforeSnapshot(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
registry := tools.NewRegistry()
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}
awaited := false
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
ProviderName: "test",
ModelName: "test-model",
Provider: provider,
Registry: registry,
AwaitToolReadiness: func(context.Context) {
awaited = true
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
},
})
m.input.SetValue("inspect the workspace")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd == nil {
t.Fatal("expected prompt submit to start an agent run")
}
next.Update(execCmd(cmd))
if !awaited {
t.Fatal("agent run did not await tool readiness")
}
if len(provider.requests) != 1 {
t.Fatalf("provider requests = %d, want 1", len(provider.requests))
}
for _, definition := range provider.requests[0].Tools {
if definition.Name == "read_file" {
return
}
}
t.Fatal("provider request omitted tool published by readiness callback")
}
func TestPromptBuildsTurnSessionForCurrentProvider(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
}}
profile := config.ProviderProfile{Name: "chatgpt", Model: "gpt-test"}
called := false
m := newModel(context.Background(), Options{
Cwd: t.TempDir(),
ProviderName: profile.Name,
ModelName: profile.Model,
ProviderProfile: profile,
Provider: provider,
Registry: tools.NewRegistry(),
NewTurnSessionProvider: func(gotProfile config.ProviderProfile, gotProvider zeroruntime.Provider) zeroruntime.TurnSessionProvider {
called = true
if gotProfile.Name != profile.Name || gotProfile.Model != profile.Model || gotProvider != provider {
t.Fatalf("turn session factory inputs = %#v/%#v", gotProfile, gotProvider)
}
return zeroruntime.NewProviderTurnSessionProvider(gotProvider, zeroruntime.ProviderCapabilities{})
},
})
m.input.SetValue("inspect the workspace")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
next.Update(execCmd(cmd))
if !called {
t.Fatal("agent run did not build a turn session for the current provider")
}
}
func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
@@ -548,10 +623,10 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1-mini" || next.provider != nextProvider {
@@ -591,10 +666,10 @@ func TestModelCommandAcceptsChatGPTCatalogModelID(t *testing.T) {
}
m.input.SetValue("/model openai/gpt-5.6-sol")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-5.6-sol" || next.provider != nextProvider {
@@ -663,10 +738,10 @@ func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
})
m.input.SetValue("/model gpt-4.1-mini")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-4.1-mini" {
@@ -2868,6 +2943,8 @@ func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) {
terminalFocused: true,
lastCharTime: base,
composerCursorVisible: true,
composerBlinkSeq: 1,
composerBlinkTicking: true,
}
// Each iteration simulates a keystroke (refreshing lastCharTime) followed by
@@ -2876,7 +2953,7 @@ func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) {
for i := 0; i < 3; i++ {
now = now.Add(200 * time.Millisecond)
m.lastCharTime = now
updated, _ := m.Update(composerBlinkMsg{})
updated, _ := m.Update(composerBlinkMsg{seq: m.composerBlinkSeq})
m = updated.(model)
if !m.composerCursorVisible {
t.Fatalf("tick %d: expected cursor to stay visible while typing, got hidden", i)
@@ -2884,6 +2961,13 @@ func TestComposerBlinkStaysSolidWhileTyping(t *testing.T) {
}
}
func TestMainComposerDisablesEmbeddedCursorBlink(t *testing.T) {
m := newModel(t.Context(), Options{})
if m.input.Styles().Cursor.Blink {
t.Fatal("embedded composer cursor blink must stay disabled")
}
}
func TestComposerBlinkHiddenWhileUnfocused(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
m := model{
@@ -2920,10 +3004,10 @@ func TestComposerBlinkResumesAfterRefocusAndIdle(t *testing.T) {
t.Fatal("expected terminalFocused to be true after FocusMsg")
}
updated, _ = m.Update(composerBlinkMsg{})
updated, _ = m.Update(composerBlinkMsg{seq: m.composerBlinkSeq})
m = updated.(model)
first := m.composerCursorVisible
updated, _ = m.Update(composerBlinkMsg{})
updated, _ = m.Update(composerBlinkMsg{seq: m.composerBlinkSeq})
m = updated.(model)
second := m.composerCursorVisible
if first == second {
@@ -2931,6 +3015,34 @@ func TestComposerBlinkResumesAfterRefocusAndIdle(t *testing.T) {
}
}
func TestComposerBlinkRejectsTickFromBeforeRefocus(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
m := model{
now: func() time.Time { return base },
terminalFocused: true,
lastCharTime: base.Add(-time.Hour),
composerCursorVisible: true,
composerBlinkSeq: 5,
composerBlinkTicking: true,
}
staleSeq := m.composerBlinkSeq
updated, _ := m.Update(tea.BlurMsg{})
m = updated.(model)
updated, refocusCmd := m.Update(tea.FocusMsg{})
m = updated.(model)
if refocusCmd == nil || m.composerBlinkSeq == staleSeq {
t.Fatal("refocus did not re-arm composer blinking with a new sequence")
}
visible := m.composerCursorVisible
updated, staleCmd := m.Update(composerBlinkMsg{seq: staleSeq})
m = updated.(model)
if staleCmd != nil || m.composerCursorVisible != visible {
t.Fatalf("stale tick changed composer state: cmd=%v visible=%v want=%v", staleCmd != nil, m.composerCursorVisible, visible)
}
}
func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) {
base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
m := model{
@@ -2938,20 +3050,53 @@ func TestComposerBlinkTogglesWhenIdleAndFocused(t *testing.T) {
terminalFocused: true,
lastCharTime: base.Add(-time.Hour),
composerCursorVisible: true,
composerBlinkSeq: 1,
composerBlinkTicking: true,
}
updated, _ := m.Update(composerBlinkMsg{})
updated, _ := m.Update(composerBlinkMsg{seq: m.composerBlinkSeq})
m = updated.(model)
if m.composerCursorVisible {
t.Fatal("expected cursor to toggle off on first idle+focused tick")
}
updated, _ = m.Update(composerBlinkMsg{})
updated, _ = m.Update(composerBlinkMsg{seq: m.composerBlinkSeq})
m = updated.(model)
if !m.composerCursorVisible {
t.Fatal("expected cursor to toggle back on on second idle+focused tick")
}
}
func TestComposerBlinkSettlesVisibleWithoutRescheduling(t *testing.T) {
m := model{
now: func() time.Time { return time.Unix(100, 0) },
terminalFocused: true,
lastCharTime: time.Unix(0, 0),
composerCursorVisible: true,
composerBlinkSeq: 1,
composerBlinkTicking: true,
}
var cmd tea.Cmd
for range composerBlinkIdleTicksBeforeSettle {
var updated tea.Model
updated, cmd = m.Update(composerBlinkMsg{seq: m.composerBlinkSeq})
m = updated.(model)
}
if m.composerBlinkTicking || cmd != nil || !m.composerCursorVisible {
t.Fatalf("settled cursor = ticking:%v cmd:%v visible:%v", m.composerBlinkTicking, cmd != nil, m.composerCursorVisible)
}
}
func TestComposerInputRestartsSettledBlink(t *testing.T) {
m := newModel(t.Context(), Options{})
m.composerBlinkTicking = false
m.composerCursorVisible = true
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'a', Text: "a"}))
m = updated.(model)
if !m.composerBlinkTicking || cmd == nil || !m.composerCursorVisible {
t.Fatalf("restarted cursor = ticking:%v cmd:%v visible:%v", m.composerBlinkTicking, cmd != nil, m.composerCursorVisible)
}
}
func TestComposerCursorShowsImmediatelyOnKeypress(t *testing.T) {
// The blink phase may have just hidden the caret when the user starts
// typing; the typed character must render with a caret immediately, not
+28 -27
View File
@@ -22,24 +22,21 @@ import (
// Options configures the reusable Zero terminal UI shell.
type Options struct {
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecentModels []config.RecentModelEntry
RecapsEnabled bool
// CompactionModel is the resolved preferences.compactionModel value; see
// providers.CompactionModelID for how it combines with the env override
// and the curated cheap defaults.
CompactionModel string
Cwd string
Version string // CLI build version, shown on the home screen; empty hides it
UserConfigPath string
DoctorUserConfigPath string
ProjectConfigPath string
ProviderName string
ModelName string
ProviderProfile config.ProviderProfile
SavedProviders []config.ProviderProfile // all configured providers, for the /model multi-provider list
FavoriteModels []string
RecentModels []config.RecentModelEntry
RecapsEnabled bool
Provider zeroruntime.Provider
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
NewTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
DiscoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
DiscoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
@@ -47,17 +44,21 @@ type Options struct {
PrepareRunCompletionWarning func()
RunCompletionWarning func() string
Registry *tools.Registry
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
PeerService *peermsg.Service
// AwaitToolReadiness gives prompt-critical integration startup a bounded
// chance to publish its tools before this turn snapshots the registry. The
// wait runs inside the asynchronous agent command, so the TUI stays usable.
AwaitToolReadiness func(context.Context)
SessionStore *sessions.Store
SandboxStore *sandbox.GrantStore
MCPConfig config.MCPConfig
MCPPermissionStore *mcp.PermissionStore
MCPTokenStore *mcp.TokenStore
MCPCommand func(context.Context, []string) MCPCommandResult
SandboxSetupCommand func(context.Context) SandboxSetupCommandResult
UsageTracker *usage.Tracker
SessionCompactor SessionCompactor
PrService *PrService
PeerService *peermsg.Service
AgentOptions agent.Options
// LoadSkills returns the installed skills (default skills dir merged with any
+4 -4
View File
@@ -521,9 +521,9 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) {
})
m.input.SetValue("/model openai/gpt-4.1")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /model to be handled without starting a run")
}
if captured.Model != "openai/gpt-4.1" {
@@ -867,9 +867,9 @@ func TestModelCommandAcceptsManualModelForCustomProvider(t *testing.T) {
})
m.input.SetValue("/model qwen-custom-latest")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /model to be handled without starting a run")
}
if captured.Model != "qwen-custom-latest" {
+6 -6
View File
@@ -46,10 +46,10 @@ func TestEffortCommandListsAndSetsSupportedEffort(t *testing.T) {
}
next.input.SetValue("/effort high")
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /effort high to be handled without starting an agent run")
}
if next.reasoningEffort != modelregistry.ReasoningEffortHigh {
@@ -153,10 +153,10 @@ func TestStyleCommandListsAndSetsSessionPreference(t *testing.T) {
}
next.input.SetValue("/style explanatory")
updated, cmd = next.Update(testKey(tea.KeyEnter))
updated, _ = next.Update(testKey(tea.KeyEnter))
next = updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /style explanatory to be handled without starting an agent run")
}
if next.responseStyle != "explanatory" {
@@ -915,10 +915,10 @@ func TestModelSwitchClearsUnsupportedEffortPreference(t *testing.T) {
})
m.input.SetValue("/model gpt-4.1")
updated, cmd := m.Update(testKey(tea.KeyEnter))
updated, _ := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
if next.pending {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.reasoningEffort != "" {
+1 -8
View File
@@ -247,14 +247,7 @@ func (m model) cancelSpecReview() (tea.Model, tea.Cmd) {
}
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
return registry.Clone()
}
// renderFocusedSpecReviewPrompt draws the spec-review gate in the shared card
+23 -27
View File
@@ -52,9 +52,8 @@ const (
)
type transientNotice struct {
text string
tone transientNoticeTone
expiresAt time.Time
text string
tone transientNoticeTone
}
// transientNoticeExpiredMsg is sequence-gated so an older timer can never
@@ -72,17 +71,29 @@ func (m model) showTransientNotice(text string, tone transientNoticeTone) (model
return m, nil
}
seq := m.transientNoticeSeq
return m, tea.Tick(transientNoticeDuration, func(time.Time) tea.Msg {
m.transientNoticeTimerSeq = seq
return m, transientNoticeExpiryCmd(seq)
}
// showTransientNoticeInline is for handlers that cannot return a tea.Cmd. The
// outer Update path notices the new sequence and schedules its one-shot expiry.
func (m model) showTransientNoticeInline(text string, tone transientNoticeTone) model {
m, _ = m.setTransientNotice(text, tone)
return m
}
func transientNoticeExpiryCmd(seq int) tea.Cmd {
return tea.Tick(transientNoticeDuration, func(time.Time) tea.Msg {
return transientNoticeExpiredMsg{seq: seq}
})
}
// showTransientNoticeInline is for handlers that cannot return a tea.Cmd. The
// composer blink is already a permanent, low-cost UI tick, so it clears this
// notice within one blink after expiry without introducing a second timer chain.
func (m model) showTransientNoticeInline(text string, tone transientNoticeTone) model {
m, _ = m.setTransientNotice(text, tone)
return m
func (m model) ensureTransientNoticeTimer() (model, tea.Cmd) {
if m.transientNotice.text == "" || m.transientNoticeTimerSeq == m.transientNoticeSeq {
return m, nil
}
m.transientNoticeTimerSeq = m.transientNoticeSeq
return m, transientNoticeExpiryCmd(m.transientNoticeSeq)
}
func (m model) setTransientNotice(text string, tone transientNoticeTone) (model, bool) {
@@ -91,28 +102,13 @@ func (m model) setTransientNotice(text string, tone transientNoticeTone) (model,
return m, false
}
m.transientNotice = transientNotice{
text: text,
tone: tone,
expiresAt: m.noticeNow().Add(transientNoticeDuration),
text: text,
tone: tone,
}
m.transientNoticeSeq++
return m, true
}
func (m model) noticeNow() time.Time {
if m.now != nil {
return m.now()
}
return time.Now()
}
func (m model) expireTransientNotice() model {
if expiresAt := m.transientNotice.expiresAt; !expiresAt.IsZero() && !m.noticeNow().Before(expiresAt) {
m.transientNotice = transientNotice{}
}
return m
}
func (m model) transientNoticeLine(width int) string {
notice := m.transientNotice
if strings.TrimSpace(notice.text) == "" {
+8 -5
View File
@@ -28,15 +28,18 @@ func TestTransientNoticeReplacesAndExpiresSafely(t *testing.T) {
}
}
func TestInlineTransientNoticeExpiresOnComposerTick(t *testing.T) {
now := time.Unix(100, 0)
m := model{now: func() time.Time { return now }}
func TestInlineTransientNoticeSchedulesIndependentExpiry(t *testing.T) {
m := model{}
m = m.showTransientNoticeInline("Voice mode on", transientNoticeSuccess)
if m.transientNotice.text != "Voice mode on" {
t.Fatalf("notice = %q", m.transientNotice.text)
}
now = now.Add(transientNoticeDuration)
next, _ := m.updateModel(composerBlinkMsg{})
next, cmd := m.Update(struct{}{})
m = next.(model)
if cmd == nil || m.transientNoticeTimerSeq != m.transientNoticeSeq {
t.Fatal("inline notice did not schedule its independent expiry")
}
next, _ = m.updateModel(transientNoticeExpiredMsg{seq: m.transientNoticeSeq})
if got := next.(model).transientNotice.text; got != "" {
t.Fatalf("expired inline notice = %q", got)
}
+6 -2
View File
@@ -135,7 +135,7 @@ func CollectStreamWithOptions(ctx context.Context, events <-chan StreamEvent, op
options.OnReasoning(event.Content)
}
case StreamEventToolCallStart:
collector.start(event.ToolCallID, event.ToolName, event.ToolCallSignature)
collector.start(event.ToolCallID, event.ToolCallProviderID, event.ToolName, event.ToolCallSignature, event.ToolCallFreeform)
if options.OnToolCallStart != nil {
options.OnToolCallStart(event.ToolCallID, event.ToolName)
}
@@ -225,7 +225,7 @@ func newToolCallCollector() *toolCallCollector {
// start begins a tool call. A non-empty ID reuses any open call with that ID
// (some backends re-emit the same start); an empty ID always begins a fresh
// synthetic call so concurrent empty-id calls stay distinct.
func (collector *toolCallCollector) start(id string, name string, signature string) {
func (collector *toolCallCollector) start(id string, providerCallID string, name string, signature string, freeform bool) {
key := id
if id == "" {
// Adopt an empty-id call that a delta opened before this start, so its
@@ -240,6 +240,9 @@ func (collector *toolCallCollector) start(id string, name string, signature stri
}
}
call := collector.ensure(key, id)
if providerCallID != "" && call.ProviderCallID == "" {
call.ProviderCallID = providerCallID
}
// Only set the name when non-empty and still unset, so a duplicate or
// nameless follow-up start cannot clobber an already-resolved name.
if name != "" && call.Name == "" {
@@ -250,6 +253,7 @@ func (collector *toolCallCollector) start(id string, name string, signature stri
if signature != "" && call.Signature == "" {
call.Signature = signature
}
call.Freeform = call.Freeform || freeform
}
func (collector *toolCallCollector) delta(id string, fragment string) {
@@ -0,0 +1,65 @@
package zeroruntime
import (
"context"
"testing"
)
func TestToolCallCollectorPreservesProviderMetadataAndFreeformState(t *testing.T) {
collector := newToolCallCollector()
collector.start("public-1", "provider-1", "apply_patch", "sig-1", true)
collector.delta("public-1", "raw patch")
collected := &CollectedStream{}
collector.flush(collected)
if len(collected.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one", collected.ToolCalls)
}
call := collected.ToolCalls[0]
if call.ID != "public-1" || call.ProviderCallID != "provider-1" || call.Name != "apply_patch" ||
call.Signature != "sig-1" || call.Arguments != "raw patch" || !call.Freeform {
t.Fatalf("collected tool call lost provider metadata: %#v", call)
}
}
func TestToolCallCollectorPreservesEmptyPublicID(t *testing.T) {
collector := newToolCallCollector()
collector.start("", "provider-1", "read_file", "", false)
collector.delta("", `{"path":"README.md"}`)
collector.end("")
collected := &CollectedStream{}
collector.flush(collected)
if len(collected.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one", collected.ToolCalls)
}
call := collected.ToolCalls[0]
if call.ID != "" || call.ProviderCallID != "provider-1" || call.Name != "read_file" {
t.Fatalf("empty-public-id tool call = %#v", call)
}
}
func TestCollectStreamErrorPreservesIncompleteToolCallMetadata(t *testing.T) {
events := make(chan StreamEvent, 2)
events <- StreamEvent{
Type: StreamEventToolCallStart,
ToolCallID: "public-1",
ToolCallProviderID: "provider-1",
ToolName: "apply_patch",
ToolCallFreeform: true,
}
events <- StreamEvent{Type: StreamEventError, Error: "stream failed"}
close(events)
collected := CollectStreamWithOptions(context.Background(), events, CollectOptions{})
if collected.Error != "stream failed" {
t.Fatalf("collected error = %q, want stream failed", collected.Error)
}
if len(collected.ToolCalls) != 1 {
t.Fatalf("tool calls = %#v, want one incomplete call", collected.ToolCalls)
}
call := collected.ToolCalls[0]
if call.ProviderCallID != "provider-1" || !call.Freeform {
t.Fatalf("incomplete tool call lost provider metadata: %#v", call)
}
}
+48 -20
View File
@@ -67,9 +67,14 @@ const (
// ToolCall is a normalized assistant request to run a tool.
type ToolCall struct {
ID string
Name string
Arguments string
ID string
ProviderCallID string
Name string
Arguments string
// Freeform reports that Arguments is raw tool input rather than a JSON
// object. Only providers that advertise a matching freeform definition set
// it; ordinary function calls remain unchanged.
Freeform bool
// Signature carries a provider's opaque reasoning signature bound to this call
// (Gemini attaches a thoughtSignature to a functionCall part). It must be
// echoed back with the call on later turns or the provider may reject the
@@ -93,21 +98,38 @@ type ReasoningBlock struct {
// Message is a normalized conversation turn passed to providers.
type Message struct {
Role MessageRole
Content string
ToolCalls []ToolCall
ToolCallID string
IsError bool // tool-result status; ignored for non-tool messages
ChangedFiles []string // durable tool-result mutation targets; ignored by providers
Images []ImageBlock // optional; nil for text-only messages
Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay
Role MessageRole
Content string
ToolCalls []ToolCall
ToolCallID string
ToolCallProviderID string
IsError bool // tool-result status; ignored for non-tool messages
ChangedFiles []string // durable tool-result mutation targets; ignored by providers
Images []ImageBlock // optional; nil for text-only messages
Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay
}
// ToolDefinition describes a model-visible tool and its JSON-schema parameters.
type ToolDefinitionType string
const (
ToolDefinitionFunction ToolDefinitionType = "function"
ToolDefinitionFreeform ToolDefinitionType = "custom"
)
type ToolDefinitionFormat struct {
Type string `json:"type"`
Syntax string `json:"syntax"`
Definition string `json:"definition"`
}
// ToolDefinition describes a model-visible function or freeform tool. Type is
// empty for the legacy function shape; Format is used only by freeform tools.
type ToolDefinition struct {
Name string
Description string
Parameters map[string]any
Type ToolDefinitionType
Format *ToolDefinitionFormat
}
// TokenUsage accepts provider-specific token aliases before normalization.
@@ -176,14 +198,20 @@ func (usage Usage) VisibleOutputTokens() int {
// StreamEvent is one normalized event emitted by a streaming provider.
type StreamEvent struct {
Type StreamEventType
Content string
ToolCallID string
ToolName string
ToolCallSignature string // opaque reasoning signature bound to this call (Gemini thoughtSignature)
ArgumentsFragment string
Usage Usage
Error string
Type StreamEventType
Content string
ToolCallID string
ToolCallProviderID string
ToolName string
ToolCallSignature string // opaque reasoning signature bound to this call (Gemini thoughtSignature)
ToolCallFreeform bool
ArgumentsFragment string
Usage Usage
Error string
// ResponseID is the provider's completed response identifier when one is
// available. Stateful turn sessions use it to chain a later compatible
// request; ordinary providers and consumers leave it empty.
ResponseID string
// FinishReason carries the provider's normalized terminal stop reason when a
// response did not end normally (e.g. FinishReasonLength when the output hit
// the token cap, or FinishReasonContentFilter when it was filtered). It is