mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb3350ea5 | ||
|
|
865bd5c22b | ||
|
|
2749447cdc |
No files matched your search
+172
-11
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
@@ -390,6 +392,27 @@ 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
|
||||
@@ -424,17 +447,88 @@ func (state *compactionState) calibratedTokens(raw int) int {
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
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,
|
||||
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,
|
||||
}
|
||||
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
|
||||
@@ -528,10 +622,34 @@ func (state *compactionState) recover(
|
||||
// context-limit error never triggers an unexpected summarization call.
|
||||
return messages, false, nil
|
||||
}
|
||||
if state.reactiveAttempted {
|
||||
if !isContextLimitError(errorMessage) {
|
||||
return messages, false, nil
|
||||
}
|
||||
if !isContextLimitError(errorMessage) {
|
||||
|
||||
// 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 {
|
||||
return messages, false, nil
|
||||
}
|
||||
|
||||
@@ -571,11 +689,21 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -659,8 +787,41 @@ 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 {
|
||||
@@ -670,13 +831,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+"("+call.Arguments+")")
|
||||
calls = append(calls, call.Name+"("+clampForSummary(call.Arguments, summaryToolArgsClamp)+")")
|
||||
}
|
||||
line += "\n[tool calls: " + strings.Join(calls, "; ") + "]"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
case zeroruntime.MessageRoleTool:
|
||||
lines = append(lines, "tool result: "+message.Content)
|
||||
lines = append(lines, "tool result: "+clampForSummary(message.Content, summaryToolResultClamp))
|
||||
default:
|
||||
lines = append(lines, string(message.Role)+": "+message.Content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -844,11 +844,21 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
options.ServiceTier = ""
|
||||
planner.config.serviceTier = ""
|
||||
options.Trace.Counter(trace.CounterModelSwitches, 1)
|
||||
// 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.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
|
||||
func TestAgentAdvertisesBothEditTools(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
|
||||
registry.Register(tool)
|
||||
@@ -79,9 +79,9 @@ func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
|
||||
names[definition.Name] = true
|
||||
}
|
||||
if !names["apply_patch"] {
|
||||
t.Fatal("agent must retain apply_patch for existing-file changes")
|
||||
t.Fatal("agent must retain apply_patch for multi-hunk changes")
|
||||
}
|
||||
if names["edit_file"] {
|
||||
t.Fatal("agent must not receive the ambiguous string-replacement tool")
|
||||
if !names["edit_file"] {
|
||||
t.Fatal("agent must receive edit_file for targeted exact replacements")
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ work.
|
||||
|
||||
- Choose the narrowest tool that safely accomplishes the step. Prefer native
|
||||
file tools - read_file, read_minified_file, list_directory, glob, grep,
|
||||
write_file, apply_patch - over shelling out to
|
||||
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
|
||||
@@ -61,11 +61,18 @@ work.
|
||||
- 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.
|
||||
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
|
||||
and enough unchanged context to identify the intended location. Match the
|
||||
existing indentation, imports, and idioms. Match the file's comment density:
|
||||
do not add explanatory comments unless the user asks or the code is already
|
||||
- For edits to existing files, use edit_file for a targeted change (old_string
|
||||
must match the file exactly and be unique, so include a few surrounding
|
||||
lines) and apply_patch when one coherent change spans several hunks or
|
||||
files. Use write_file only to create a file or when most of it changes; do
|
||||
not rewrite a whole file to change a few lines. Match the existing
|
||||
indentation, imports, and idioms. Match the file's comment density: do not
|
||||
add explanatory comments unless the user asks or the code is already
|
||||
comment-dense.
|
||||
- A successful edit result already confirms the change; do not re-read a file
|
||||
just to verify an edit that succeeded. If an edit fails, read the error, fix
|
||||
the old_string or hunk, and retry the same tool rather than switching to a
|
||||
full rewrite.
|
||||
- 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
|
||||
@@ -105,7 +112,7 @@ work.
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/apply_patch for file changes.
|
||||
with exec_command, or use edit_file/apply_patch/write_file for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -60,14 +60,18 @@ 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,
|
||||
write_file, apply_patch) over shelling out to cat/sed/awk/python for
|
||||
file work. Make one tool call per file; do not batch file writes into a script.
|
||||
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.
|
||||
- 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>`
|
||||
|
||||
const geminiPromptAddendum = `<model_guidance>
|
||||
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
|
||||
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
|
||||
equivalent shell commands; they are safer and produce cleaner diffs.
|
||||
- Be concise and concrete. When you run a shell command with side effects, state
|
||||
in one short clause why it is needed.
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
"inspect the target file",
|
||||
"plan then act",
|
||||
"choose the narrowest tool",
|
||||
"for edits to existing files, prefer apply_patch",
|
||||
"for edits to existing files, use edit_file",
|
||||
"verify after edits",
|
||||
"honor the active permission mode",
|
||||
"avoid broad refactors",
|
||||
|
||||
+20
-4
@@ -348,10 +348,26 @@ type Options struct {
|
||||
// CompactionPreserveLast is how many trailing messages compaction keeps
|
||||
// verbatim. <= 0 falls back to defaultCompactionPreserveLast.
|
||||
CompactionPreserveLast int
|
||||
Registry *tools.Registry
|
||||
PermissionMode PermissionMode
|
||||
Autonomy string
|
||||
Sandbox *sandbox.Engine
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
@@ -948,6 +948,7 @@ 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,
|
||||
|
||||
+14
-10
@@ -682,16 +682,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
ModelSwitcher: modelSwitcher,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
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),
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
||||
@@ -119,6 +119,12 @@ 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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyPatchPathBlockOnlyRejectsRelativeTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
inside := filepath.Join(root, "main.js")
|
||||
structured := func(path string) string {
|
||||
return strings.Join([]string{"*** Begin Patch", "*** Update File: " + path, "@@", "-a", "+b", "*** End Patch"}, "\n")
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"absolute inside workspace": structured(inside),
|
||||
"relative": structured("main.js"),
|
||||
"decorated markers": "*** Begin Patch ***\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch ***",
|
||||
"no-space marker": "***Begin Patch\n*** Update File: main.js\n@@\n-a\n+b\n***End Patch",
|
||||
} {
|
||||
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
|
||||
if block := applyPatchPathBlock(request); block != nil {
|
||||
t.Fatalf("%s: unexpected block %+v", name, block)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"../escape.js", ".."} {
|
||||
for name, patch := range map[string]string{"canonical": structured(path), "no-space": "***Begin Patch\n*** Update File: " + path + "\n@@\n-a\n+b\n***End Patch"} {
|
||||
request := Request{ToolName: "apply_patch", WorkspaceRoot: root, SideEffect: SideEffectWrite, Args: map[string]any{"patch": patch}}
|
||||
block := applyPatchPathBlock(request)
|
||||
if block == nil || block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("%s %q must be blocked as traversal, got %+v", name, path, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every marker spelling the tool applies must be classified as structured at
|
||||
// the sandbox boundary too; otherwise the boundary scans the patch as a unified
|
||||
// diff, extracts no targets, and validates nothing (fail-open).
|
||||
func TestStructuredPatchClassifierMatchesToolSpellings(t *testing.T) {
|
||||
for _, header := range []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "\ufeff*** Begin Patch"} {
|
||||
patch := header + "\n*** Update File: main.js\n@@\n-a\n+b\n*** End Patch"
|
||||
if !IsStructuredPatch(patch) {
|
||||
t.Fatalf("%q must classify as a structured patch", header)
|
||||
}
|
||||
if paths := applyPatchPaths(patch); len(paths) != 1 || paths[0] != "main.js" {
|
||||
t.Fatalf("%q: sandbox must extract the structured target, got %v", header, paths)
|
||||
}
|
||||
}
|
||||
for _, header := range []string{"--- a/x", "Begin Patch", "*** Begin Patchwork", "*** Update File: x"} {
|
||||
if IsStructuredPatch(header + "\n-a\n+b") {
|
||||
t.Fatalf("%q must not classify as a structured patch", header)
|
||||
}
|
||||
}
|
||||
if StructuredPatchMarker("*** End Patch ***") != "end" || StructuredPatchMarker("***End Patch") != "end" {
|
||||
t.Fatal("decorated end markers must classify as end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchRequestPathsCarryAbsolutePathsToScopeValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// NewScope also grants the system temp dir, so a sibling t.TempDir() is
|
||||
// legitimately in scope; pick a path under the filesystem root instead.
|
||||
outside, err := filepath.Abs(filepath.Join(string(filepath.Separator), "zero-outside-workspace-test", "escape.js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope, err := NewScope(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inside := filepath.Join(root, "main.js")
|
||||
structured := func(header, footer, path string) string {
|
||||
return strings.Join([]string{header, "*** Update File: " + path, "@@", "-a", "+b", footer}, "\n")
|
||||
}
|
||||
for name, spelling := range map[string][2]string{"canonical": {"*** Begin Patch", "*** End Patch"}, "no-space": {"***Begin Patch", "***End Patch"}} {
|
||||
// Failure path: the exact path the boundary parsed must be denied by the
|
||||
// scope. structuredPatchHeaderPaths normalises separators to "/", so the
|
||||
// parsed form is compared in slash form and then validated as-is.
|
||||
paths := applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], outside)})
|
||||
if len(paths) != 1 || paths[0] != filepath.ToSlash(outside) {
|
||||
t.Fatalf("%s: absolute patch path must reach scope validation unchanged, got %v", name, paths)
|
||||
}
|
||||
if block := scope.validate(paths[0]); block == nil || block.Code != BlockOutsideWorkspace {
|
||||
t.Fatalf("%s: scope must deny the parsed outside path %q, got %+v", name, paths[0], block)
|
||||
}
|
||||
// Success path: the parsed inside path must be accepted by the scope.
|
||||
paths = applyPatchRequestPaths(map[string]any{"patch": structured(spelling[0], spelling[1], inside)})
|
||||
if len(paths) != 1 || paths[0] != filepath.ToSlash(inside) {
|
||||
t.Fatalf("%s: inside patch path must reach scope validation unchanged, got %v", name, paths)
|
||||
}
|
||||
if block := scope.validate(paths[0]); block != nil {
|
||||
t.Fatalf("%s: scope must accept the parsed inside path %q, got %+v", name, paths[0], block)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,11 +281,15 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
if patch == "" {
|
||||
return nil
|
||||
}
|
||||
// Only relative traversal is rejected up front. Absolute paths flow through
|
||||
// the regular workspace-scope validation below (requestPaths), which accepts
|
||||
// one inside the workspace and denies one outside — a model that echoes the
|
||||
// absolute path read_file showed it must not be blocked for that alone.
|
||||
for _, path := range applyPatchPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
|
||||
if path == ".." || strings.HasPrefix(path, "../") {
|
||||
return &pathBlock{
|
||||
Code: BlockOutsideWorkspace,
|
||||
Path: path,
|
||||
@@ -296,8 +300,35 @@ func applyPatchPathBlock(request Request) *pathBlock {
|
||||
return nil
|
||||
}
|
||||
|
||||
// structuredPatchMarkerPattern is the single classifier for structured-patch
|
||||
// markers, shared by the sandbox boundary and the apply_patch tool (which
|
||||
// imports this package). It accepts the canonical "*** Begin Patch" /
|
||||
// "*** End Patch" and the decorated spellings models emit ("*** Begin Patch ***",
|
||||
// "***Begin Patch", trailing whitespace). Both sides must agree: a spelling the
|
||||
// tool would apply but the sandbox did not recognise would make the sandbox
|
||||
// scan the patch as a unified diff, extract no targets, and validate nothing.
|
||||
var structuredPatchMarkerPattern = regexp.MustCompile(`^\*{3}\s*(Begin|End) Patch\s*\**\s*$`)
|
||||
|
||||
// StructuredPatchMarker classifies a line as the "begin" or "end" marker of a
|
||||
// structured patch, or "" when it is neither.
|
||||
func StructuredPatchMarker(line string) string {
|
||||
match := structuredPatchMarkerPattern.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if match == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(match[1])
|
||||
}
|
||||
|
||||
// IsStructuredPatch reports whether patch opens with a structured begin marker.
|
||||
// The tool applies exactly the patches this returns true for, so the sandbox
|
||||
// extracts structured header paths for exactly the same set.
|
||||
func IsStructuredPatch(patch string) bool {
|
||||
first, _, _ := strings.Cut(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "\n")
|
||||
return StructuredPatchMarker(first) == "begin"
|
||||
}
|
||||
|
||||
func applyPatchPaths(patch string) []string {
|
||||
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
|
||||
if IsStructuredPatch(patch) {
|
||||
return structuredPatchHeaderPaths(patch)
|
||||
}
|
||||
return patchHeaderPaths(patch)
|
||||
|
||||
+32
-187
@@ -3,11 +3,11 @@ package tools
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
type applyPatchTool struct {
|
||||
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return applyPatchTool{
|
||||
baseTool: baseTool{
|
||||
name: "apply_patch",
|
||||
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
|
||||
description: "Apply a multi-hunk or multi-file patch inside the workspace (or a granted extra write root). Structured format:\n*** Begin Patch\n*** Update File: src/app.js\n@@\n unchanged context line\n-removed line\n+added line\n*** End Patch\nUse \"*** Add File: path\" with \"+\" lines to create a file and \"*** Delete File: path\" to remove one; several sections may follow each other. A unified diff (---/+++ headers with a/ b/ prefixes, @@ hunks) is also accepted and applied in-process. Paths are workspace-relative; absolute paths inside the workspace are fine. For a single targeted change, edit_file is simpler.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
|
||||
"patch": {Type: "string", Description: "The structured (*** Begin Patch) or unified-diff patch text."},
|
||||
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
|
||||
},
|
||||
Required: []string{"patch"},
|
||||
@@ -59,177 +59,14 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
|
||||
if isStructuredPatch(patch) {
|
||||
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
|
||||
}
|
||||
if err := validatePatchPaths(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("", "zero-patch-*.patch")
|
||||
// Unified diffs are translated into the same operations and applied by
|
||||
// the same os.Root engine, so neither format opens a target by pathname
|
||||
// after validation (no check-to-use window) and git is not needed.
|
||||
operations, err := parseUnifiedPatch(patch)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
patchPath := tempFile.Name()
|
||||
defer func() {
|
||||
_ = os.Remove(patchPath)
|
||||
}()
|
||||
if _, err := tempFile.WriteString(patch); err != nil {
|
||||
_ = tempFile.Close()
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
|
||||
if err := recheckPatchWriteTargets(applyRoot, patch); err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
var createdTargets []string
|
||||
var fullySuppliedTargets []string
|
||||
wholeBefore := map[string]bool{}
|
||||
if options.FileTracker != nil {
|
||||
createdTargets = missingPatchTargets(applyRoot, patch)
|
||||
fullySuppliedTargets = completeCreatedPatchTargets(applyRoot, patch)
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if absolute, _, rerr := resolveWorkspaceTargetPath(applyRoot, path); rerr == nil {
|
||||
wholeBefore[absolute] = options.FileTracker.SeenWhole(absolute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command := exec.CommandContext(ctx, "git", "apply", "--whitespace=nowarn", patchPath)
|
||||
command.Dir = applyRoot
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
message := strings.TrimSpace(string(output))
|
||||
if message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
return errorResult("Error applying patch: " + message)
|
||||
}
|
||||
|
||||
summary := "Patch applied successfully."
|
||||
if relativeRoot != "." {
|
||||
summary = "Patch applied successfully in " + relativeRoot + "."
|
||||
}
|
||||
result := okResult(summary)
|
||||
result.ChangedFiles = changedFilesFromPatch(relativeRoot, patch)
|
||||
result.Display = Display{Summary: summary, Kind: "diff", Preview: capPreviewDiff(patch)}
|
||||
fullySupplied := make(map[string]bool, len(fullySuppliedTargets))
|
||||
for _, absolute := range fullySuppliedTargets {
|
||||
fullySupplied[absolute] = true
|
||||
}
|
||||
// Re-baseline files changed by this tool. When the model had already seen the
|
||||
// whole input (or supplied a complete new file), the exact patch plus that
|
||||
// input determines the whole output, so a follow-up edit needs no wasted read.
|
||||
// Partial observations remain conservative and are cleared by Record.
|
||||
for _, changed := range result.ChangedFiles {
|
||||
if absolute, _, rerr := resolveScopedPath(tool.workspaceRoot, tool.scope, changed); rerr == nil {
|
||||
content, readErr := os.ReadFile(absolute)
|
||||
if readErr != nil {
|
||||
options.FileTracker.Forget(absolute)
|
||||
continue
|
||||
}
|
||||
info, _ := os.Stat(absolute)
|
||||
wasWhole := wholeBefore[absolute] || fullySupplied[absolute]
|
||||
options.FileTracker.Record(absolute, content, info)
|
||||
if wasWhole {
|
||||
lines := lineCount(string(content))
|
||||
options.FileTracker.RecordSeenRange(absolute, 1, lines, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
recordCreatedPatchTargets(options.FileTracker, createdTargets)
|
||||
return result
|
||||
}
|
||||
|
||||
func missingPatchTargets(root string, patch string) []string {
|
||||
seen := map[string]bool{}
|
||||
var missing []string
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
absolute, _, err := resolveWorkspaceTargetPath(root, path)
|
||||
if err != nil || seen[absolute] {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = true
|
||||
if _, err := os.Stat(absolute); os.IsNotExist(err) {
|
||||
missing = append(missing, absolute)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// completeCreatedPatchTargets returns only files whose full contents are
|
||||
// supplied by a /dev/null creation patch. A missing rename/copy destination is
|
||||
// created by git too, but its bytes come from an unread source and must not gain
|
||||
// whole-file observation credit.
|
||||
func completeCreatedPatchTargets(root string, patch string) []string {
|
||||
seen := map[string]bool{}
|
||||
var created []string
|
||||
oldRemaining, newRemaining := 0, 0
|
||||
inHunk := false
|
||||
fromDevNull := false
|
||||
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "-"):
|
||||
oldRemaining--
|
||||
case strings.HasPrefix(line, "+"):
|
||||
newRemaining--
|
||||
case strings.HasPrefix(line, "\\"):
|
||||
default:
|
||||
oldRemaining--
|
||||
newRemaining--
|
||||
}
|
||||
continue
|
||||
}
|
||||
inHunk = false
|
||||
switch {
|
||||
case strings.HasPrefix(line, "diff --git "):
|
||||
fromDevNull = false
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
oldRemaining, newRemaining = parseHunkCounts(line)
|
||||
inHunk = oldRemaining > 0 || newRemaining > 0
|
||||
case strings.HasPrefix(line, "--- "):
|
||||
fromDevNull = patchFileHeaderPath(line) == "/dev/null"
|
||||
case strings.HasPrefix(line, "+++ "):
|
||||
path := patchFileHeaderPath(line)
|
||||
if !fromDevNull || path == "" || path == "/dev/null" {
|
||||
fromDevNull = false
|
||||
continue
|
||||
}
|
||||
fromDevNull = false
|
||||
absolute, _, err := resolveWorkspaceTargetPath(root, stripPatchPrefix(path))
|
||||
if err != nil || seen[absolute] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(absolute); !os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
seen[absolute] = true
|
||||
created = append(created, absolute)
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func recordCreatedPatchTargets(tracker *FileTracker, missingBefore []string) {
|
||||
if tracker == nil {
|
||||
return
|
||||
}
|
||||
for _, absolute := range missingBefore {
|
||||
if _, err := os.Stat(absolute); err != nil {
|
||||
continue
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
|
||||
absolute = resolved
|
||||
}
|
||||
tracker.RecordCreated(absolute)
|
||||
}
|
||||
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
|
||||
}
|
||||
|
||||
// changedFilesFromPatch extracts the unique, WORKSPACE-relative paths a patch
|
||||
@@ -260,27 +97,35 @@ func changedFilesFromPatch(relativeRoot string, patch string) []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
// normalizePatchPathForRoot resolves platform-level symlinks (macOS /var ->
|
||||
// /private/var) in the prefix of an absolute patch path that lies outside the
|
||||
// apply root, so a path the model copied from read_file compares equal to the
|
||||
// symlink-resolved root. Relative paths are returned unchanged.
|
||||
func normalizePatchPathForRoot(root string, path string) string {
|
||||
if !filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
resolvedRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
if evaluated, err := filepath.EvalSymlinks(resolvedRoot); err == nil {
|
||||
resolvedRoot = evaluated
|
||||
}
|
||||
return sandbox.NormalizePrefixForRoot(path, resolvedRoot)
|
||||
}
|
||||
|
||||
func validatePatchPaths(root string, patch string) error {
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, "../") {
|
||||
// Relative traversal is rejected here; an absolute path is checked by
|
||||
// resolveWorkspaceTargetPath, which only accepts one inside the root.
|
||||
if path == ".." || strings.HasPrefix(path, "../") {
|
||||
return fmt.Errorf("patch path %q must stay inside the workspace", path)
|
||||
}
|
||||
if _, _, err := resolveWorkspaceTargetPath(root, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recheckPatchWriteTargets(root string, patch string) error {
|
||||
for _, path := range patchHeaderPaths(patch) {
|
||||
if path == "" || path == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if err := recheckWorkspaceWriteTarget(root, path); err != nil {
|
||||
if _, _, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -351,7 +196,7 @@ func patchFileHeaderPath(line string) string {
|
||||
// tokens. Scanning the whole line would let a crafted heading like
|
||||
// "@@ -1,1 +1,1 @@ +1,999999" overwrite the real count, keep the parser stuck in
|
||||
// hunk mode, and swallow later "--- "/"+++ " file headers so they escape
|
||||
// validatePatchPaths / recheckPatchWriteTargets — a workspace-confinement bypass.
|
||||
// validatePatchPaths — a workspace-confinement bypass.
|
||||
func parseHunkCounts(line string) (int, int) {
|
||||
_, rest, ok := strings.Cut(line, "@@")
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Models routinely decorate the structured markers ("*** Begin Patch ***") and
|
||||
// write unified-diff ranges after "@@"; both used to fail on the first line and
|
||||
// pushed the model into whole-file rewrites.
|
||||
func TestApplyPatchToleratesDecoratedMarkersAndRangeHeaders(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\nbye\n")
|
||||
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch ***",
|
||||
"*** Update File: hello.txt",
|
||||
"@@ -1,3 +1,3 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch ***",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
if !isStructuredPatch(patch) {
|
||||
t.Fatal("decorated begin marker must still be recognised as a structured patch")
|
||||
}
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("decorated structured patch should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\nbye\n" {
|
||||
t.Fatalf("patched content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredHunkAnchorKeepsHeadingAndPlainContext(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"-12,4 +12,6": "",
|
||||
"-12 +12": "",
|
||||
"-12,4 +12,6 @@": "",
|
||||
"-12,4 +12,6 @@ func main() {": "func main() {",
|
||||
"func main() {": "func main() {",
|
||||
"": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := structuredHunkAnchor(input); got != want {
|
||||
t.Fatalf("structuredHunkAnchor(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchMarkerSpellings(t *testing.T) {
|
||||
begin := []string{"*** Begin Patch", "*** Begin Patch ***", "***Begin Patch", " *** Begin Patch ", "*** Begin Patch **"}
|
||||
for _, line := range begin {
|
||||
if structuredPatchMarker(line) != "begin" {
|
||||
t.Fatalf("%q must read as the begin marker", line)
|
||||
}
|
||||
}
|
||||
if structuredPatchMarker("*** End Patch ***") != "end" {
|
||||
t.Fatal("decorated end marker must read as the end marker")
|
||||
}
|
||||
for _, line := range []string{"*** Update File: x", "Begin Patch", "*** Begin Patchwork"} {
|
||||
if structuredPatchMarker(line) != "" {
|
||||
t.Fatalf("%q must not read as a marker", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchHeaderErrorCarriesFormatHint(t *testing.T) {
|
||||
_, err := parseStructuredPatch("--- a/x\n+++ b/x\n")
|
||||
if err == nil || !strings.Contains(err.Error(), "*** Update File: path") {
|
||||
t.Fatalf("header error should teach the format, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchAcceptsAbsoluteInWorkspacePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
|
||||
absolute := filepath.Join(root, "hello.txt")
|
||||
|
||||
structured := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: " + absolute,
|
||||
"@@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+structured",
|
||||
"*** End Patch",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": structured})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("absolute in-workspace structured path should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, _ := os.ReadFile(absolute)
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nstructured\n" {
|
||||
t.Fatalf("structured content = %q", got)
|
||||
}
|
||||
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "hello.txt" {
|
||||
t.Fatalf("changed files should be workspace-relative, got %v", result.ChangedFiles)
|
||||
}
|
||||
|
||||
unified := strings.Join([]string{
|
||||
"--- " + absolute,
|
||||
"+++ " + absolute,
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-structured",
|
||||
"+unified",
|
||||
"",
|
||||
}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": unified})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("absolute in-workspace unified path should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, _ = os.ReadFile(absolute)
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nunified\n" {
|
||||
t.Fatalf("unified content = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPatchStillRejectsAbsolutePathsOutsideWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "escape.txt")
|
||||
writeTestFile(t, outside, "old\n")
|
||||
|
||||
for name, patch := range map[string]string{
|
||||
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: " + outside, "@@", "-old", "+new", "*** End Patch", ""}, "\n"),
|
||||
"unified": strings.Join([]string{"--- " + outside, "+++ " + outside, "@@ -1 +1 @@", "-old", "+new", ""}, "\n"),
|
||||
} {
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("%s patch outside the workspace must be rejected", name)
|
||||
}
|
||||
if content, _ := os.ReadFile(outside); string(content) != "old\n" {
|
||||
t.Fatalf("%s patch must not touch a file outside the workspace", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unified diffs are applied in-process through the same os.Root engine as
|
||||
// structured patches: git C-quoted paths with whitespace resolve to one file,
|
||||
// and the same header outside the workspace is refused before anything is
|
||||
// written.
|
||||
func TestUnifiedPatchQuotedWhitespacePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "work tree")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "file.txt")
|
||||
writeTestFile(t, target, "hello\nold\n")
|
||||
|
||||
quoted := func(path string) string { return strconv.Quote(path) }
|
||||
inside := strings.Join([]string{
|
||||
"diff --git " + quoted("a/"+filepath.ToSlash(target)) + " " + quoted("b/"+filepath.ToSlash(target)),
|
||||
"--- " + quoted(target),
|
||||
"+++ " + quoted(target),
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": inside})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("quoted whitespace path inside the workspace should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
content, _ := os.ReadFile(target)
|
||||
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
|
||||
t.Fatalf("content = %q", got)
|
||||
}
|
||||
if len(result.ChangedFiles) != 1 || result.ChangedFiles[0] != "work tree/file.txt" {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
|
||||
outsideDir := filepath.Join(t.TempDir(), "other tree")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(outsideDir, "file.txt")
|
||||
writeTestFile(t, outside, "hello\nold\n")
|
||||
escape := strings.Join([]string{"--- " + quoted(outside), "+++ " + quoted(outside), "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": escape})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatal("quoted whitespace path outside the workspace must be rejected")
|
||||
}
|
||||
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
|
||||
t.Fatal("outside file must be untouched")
|
||||
}
|
||||
}
|
||||
|
||||
// A workspace path that is swapped for a symlink escaping the root — the
|
||||
// classic check-to-use attack — is refused by the os.Root engine for both
|
||||
// formats, and the outside file is never modified.
|
||||
func TestApplyPatchRefusesSymlinkEscapingWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "secret.txt")
|
||||
writeTestFile(t, outside, "hello\nold\n")
|
||||
link := filepath.Join(root, "target.txt")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"unified": strings.Join([]string{"--- a/target.txt", "+++ b/target.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n"),
|
||||
"structured": strings.Join([]string{"*** Begin Patch", "*** Update File: target.txt", "@@", " hello", "-old", "+new", "*** End Patch", ""}, "\n"),
|
||||
} {
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("%s patch through an escaping symlink must be refused", name)
|
||||
}
|
||||
if content, _ := os.ReadFile(outside); string(content) != "hello\nold\n" {
|
||||
t.Fatalf("%s patch must not modify the file outside the workspace", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchCreatesDeletesAndHonoursNoNewlineMarker(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "bye\n")
|
||||
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\nb\n")
|
||||
patch := strings.Join([]string{
|
||||
"diff --git a/new.txt b/new.txt",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/new.txt",
|
||||
"@@ -0,0 +1,2 @@",
|
||||
"+first",
|
||||
"+second",
|
||||
"\\ No newline at end of file",
|
||||
"diff --git a/gone.txt b/gone.txt",
|
||||
"deleted file mode 100644",
|
||||
"--- a/gone.txt",
|
||||
"+++ /dev/null",
|
||||
"@@ -1 +0,0 @@",
|
||||
"-bye",
|
||||
"--- a/keep.txt",
|
||||
"+++ b/keep.txt",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" a",
|
||||
"-b",
|
||||
"+c",
|
||||
"\\ No newline at end of file",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("multi-file unified patch should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "new.txt")); string(content) != "first\nsecond" {
|
||||
t.Fatalf("created file = %q", string(content))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("deleted file must be gone")
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "a\nc" {
|
||||
t.Fatalf("updated file = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 3 {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchUsesRangeHintThenFallsBackToContext(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Two identical blocks: the hunk's range picks the second one, which
|
||||
// content search alone would call ambiguous.
|
||||
writeTestFile(t, filepath.Join(root, "dup.txt"), "x\ny\nx\ny\n")
|
||||
patch := strings.Join([]string{"--- a/dup.txt", "+++ b/dup.txt", "@@ -3,2 +3,2 @@", " x", "-y", "+z", ""}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("range-hinted hunk should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "dup.txt")); string(content) != "x\ny\nx\nz\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
// A stale range (file grew above the hunk) still applies by unique context.
|
||||
writeTestFile(t, filepath.Join(root, "moved.txt"), "header\nhello\nold\n")
|
||||
patch = strings.Join([]string{"--- a/moved.txt", "+++ b/moved.txt", "@@ -1,2 +1,2 @@", " hello", "-old", "+new", ""}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("offset hunk should apply by context, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "moved.txt")); string(content) != "header\nhello\nnew\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchRenameWithHunkAndRejectsBinary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "x.txt"), "hello\nold\n")
|
||||
rename := strings.Join([]string{
|
||||
"diff --git a/x.txt b/y.txt",
|
||||
"similarity index 90%",
|
||||
"rename from x.txt",
|
||||
"rename to y.txt",
|
||||
"--- a/x.txt",
|
||||
"+++ b/y.txt",
|
||||
"@@ -1,2 +1,2 @@",
|
||||
" hello",
|
||||
"-old",
|
||||
"+new",
|
||||
"",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": rename})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("rename with hunk should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "x.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("renamed source must be gone")
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "y.txt")); string(content) != "hello\nnew\n" {
|
||||
t.Fatalf("renamed content = %q", string(content))
|
||||
}
|
||||
for name, patch := range map[string]string{
|
||||
"binary": "diff --git a/x b/x\nBinary files a/x and b/x differ\n",
|
||||
"empty": "diff --git a/x b/x\n",
|
||||
} {
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status == StatusOK {
|
||||
t.Fatalf("%s patch must be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchCopyOperation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "src.txt"), "hello\nold\n")
|
||||
copyPatch := func(from, to string, hunk bool) string {
|
||||
lines := []string{"diff --git a/" + from + " b/" + to, "similarity index 90%", "copy from " + from, "copy to " + to}
|
||||
if hunk {
|
||||
lines = append(lines, "--- a/"+from, "+++ b/"+to, "@@ -1,2 +1,2 @@", " hello", "-old", "+new")
|
||||
}
|
||||
return strings.Join(append(lines, ""), "\n")
|
||||
}
|
||||
|
||||
// Success: source kept, destination created with the hunk applied, and the
|
||||
// destination inherits the source's tracker state (read whole -> whole).
|
||||
tracker := NewFileTracker()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedReadFileTool(root, nil))
|
||||
registry.Register(NewScopedApplyPatchTool(root, nil))
|
||||
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
|
||||
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "src.txt"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("read: %s", r.Output)
|
||||
}
|
||||
result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": copyPatch("src.txt", "dst.txt", true)}, opts)
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("copy with hunk should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "src.txt")); string(content) != "hello\nold\n" {
|
||||
t.Fatalf("copy must keep the source, got %q", string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "dst.txt")); string(content) != "hello\nnew\n" {
|
||||
t.Fatalf("copy destination = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 2 || result.ChangedFiles[0] != "src.txt" || result.ChangedFiles[1] != "dst.txt" {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
destination, err := filepath.EvalSymlinks(filepath.Join(root, "dst.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tracker.SeenWhole(destination) {
|
||||
t.Fatal("destination copied from a fully read source must be tracked as seen whole")
|
||||
}
|
||||
unreadSource, _ := filepath.EvalSymlinks(filepath.Join(root, "src.txt"))
|
||||
if _, tracked := tracker.Version(unreadSource); !tracked {
|
||||
t.Fatal("source must stay tracked after a copy")
|
||||
}
|
||||
|
||||
// Failure: destination already exists — nothing is written.
|
||||
writeTestFile(t, filepath.Join(root, "taken.txt"), "keep\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "taken.txt", false)})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "already exists") {
|
||||
t.Fatalf("copy onto an existing destination must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "taken.txt")); string(content) != "keep\n" {
|
||||
t.Fatal("existing destination must be untouched")
|
||||
}
|
||||
|
||||
// Failure: copy onto itself.
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": copyPatch("src.txt", "src.txt", false)})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "onto itself") {
|
||||
t.Fatalf("copy onto itself must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchRejectsOverDeclaredHunkCounts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "a.txt"), "hello\nold\n")
|
||||
writeTestFile(t, filepath.Join(root, "b.txt"), "x\n")
|
||||
// "-1,3 +1,3" over-declares a two-line hunk, so without a guard the next
|
||||
// file's ---/+++ headers would be swallowed as "-"/"+" content.
|
||||
patch := strings.Join([]string{
|
||||
"--- a/a.txt", "+++ b/a.txt", "@@ -1,3 +1,3 @@", " hello", "-old", "+new",
|
||||
"--- a/b.txt", "+++ b/b.txt", "@@ -1 +1 @@", "-x", "+y", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
|
||||
t.Fatalf("over-declared hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
for name, want := range map[string]string{"a.txt": "hello\nold\n", "b.txt": "x\n"} {
|
||||
if content, _ := os.ReadFile(filepath.Join(root, name)); string(content) != want {
|
||||
t.Fatalf("%s must be untouched after a malformed patch, got %q", name, string(content))
|
||||
}
|
||||
}
|
||||
// Truncated at end of input is reported the same way.
|
||||
truncated := "--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n hello\n-old\n"
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": truncated}); result.Status == StatusOK || !strings.Contains(result.Output, "declared line counts") {
|
||||
t.Fatalf("truncated hunk must be reported as malformed, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchNoNewlineMarkerOnlyAfterFinalHunk(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "two.txt"), "a\nb\nc\nd\ne\nf\n")
|
||||
// A marker after the first of two hunks describes nothing real and must
|
||||
// not leak into the second hunk's result: the patch is rejected untouched.
|
||||
early := strings.Join([]string{
|
||||
"--- a/two.txt", "+++ b/two.txt",
|
||||
"@@ -1,2 +1,2 @@", " a", "-b", "+B", "\\ No newline at end of file",
|
||||
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": early})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "must follow the last hunk") {
|
||||
t.Fatalf("marker before a later hunk must be rejected, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nb\nc\nd\ne\nf\n" {
|
||||
t.Fatal("rejected patch must not modify the file")
|
||||
}
|
||||
// The same marker after the final hunk applies and strips the newline.
|
||||
final := strings.Join([]string{
|
||||
"--- a/two.txt", "+++ b/two.txt",
|
||||
"@@ -1,2 +1,2 @@", " a", "-b", "+B",
|
||||
"@@ -5,2 +5,2 @@", " e", "-f", "+F", "\\ No newline at end of file", "",
|
||||
}, "\n")
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": final})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("two-hunk patch with a final marker should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "two.txt")); string(content) != "a\nB\nc\nd\ne\nF" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// A removed "-- …" line directly followed by an added "++ …" line looks like a
|
||||
// ---/+++ header pair; the boundary check must not mistake it for one.
|
||||
func TestUnifiedPatchKeepsAdjacentDashPlusContentLines(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "notes.sql"), "select 1;\n-- old comment\nselect 2;\n")
|
||||
patch := strings.Join([]string{
|
||||
"--- a/notes.sql", "+++ b/notes.sql",
|
||||
"@@ -1,3 +1,3 @@", " select 1;", "--- old comment", "+++ new comment", " select 2;", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("adjacent -- / ++ content lines must apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "notes.sql")); string(content) != "select 1;\n++ new comment\nselect 2;\n" {
|
||||
t.Fatalf("content = %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
// A unified deletion states the content it removes; it must be verified
|
||||
// against the current file before anything is deleted.
|
||||
func TestUnifiedPatchDeletionVerifiesExpectedContent(t *testing.T) {
|
||||
deletion := func(path string, hunks ...string) string {
|
||||
lines := []string{"diff --git a/" + path + " b/" + path, "deleted file mode 100644", "--- a/" + path, "+++ /dev/null"}
|
||||
return strings.Join(append(append(lines, hunks...), ""), "\n")
|
||||
}
|
||||
t.Run("matching deletion succeeds", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-current")})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("matching deletion should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("file must be deleted")
|
||||
}
|
||||
})
|
||||
t.Run("stale deletion is refused untouched", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-stale")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
|
||||
t.Fatalf("stale deletion must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "current\n" {
|
||||
t.Fatalf("file must be byte-for-byte unchanged, got %q", string(content))
|
||||
}
|
||||
})
|
||||
t.Run("partial deletion is refused", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "one\ntwo\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt", "@@ -1 +0,0 @@", "-one")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "does not match its current content") {
|
||||
t.Fatalf("deletion that does not cover the file must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "one\ntwo\n" {
|
||||
t.Fatal("file must be unchanged")
|
||||
}
|
||||
})
|
||||
t.Run("header-only deletion is refused", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("gone.txt")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "must include the hunk") {
|
||||
t.Fatalf("header-only deletion must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); err != nil {
|
||||
t.Fatal("file must still exist")
|
||||
}
|
||||
})
|
||||
t.Run("multi-hunk deletion verifies everything before removing", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\nb\nc\nd\n")
|
||||
good := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-d")
|
||||
bad := deletion("gone.txt", "@@ -1,2 +0,0 @@", "-a", "-b", "@@ -3,2 +0,0 @@", "-c", "-stale")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": bad})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("deletion with one stale hunk must be refused: %s", result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != "a\nb\nc\nd\n" {
|
||||
t.Fatal("no hunk may be applied when any hunk is stale")
|
||||
}
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": good})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("multi-hunk deletion should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("file must be deleted")
|
||||
}
|
||||
})
|
||||
t.Run("structured delete stays unconditional", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "anything\n")
|
||||
patch := "*** Begin Patch\n*** Delete File: gone.txt\n*** End Patch\n"
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
|
||||
t.Fatalf("structured delete should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The source is re-read through the root immediately before commit, so a
|
||||
// change made after planning is refused instead of overwritten or removed.
|
||||
func TestApplyStructuredPatchChangeRefusesSourceChangedAfterPlanning(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "file.txt")
|
||||
writeTestFile(t, path, "planned\n")
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer workspace.Close()
|
||||
target := structuredPatchTarget{requested: "file.txt", absolute: path, relative: "file.txt"}
|
||||
destination := structuredPatchTarget{requested: "copy.txt", absolute: filepath.Join(root, "copy.txt"), relative: "copy.txt"}
|
||||
for name, change := range map[string]structuredPatchChange{
|
||||
"update": {kind: structuredPatchUpdate, from: target, to: target, before: "planned\n", after: "new\n", mode: 0o644},
|
||||
"delete": {kind: structuredPatchDelete, from: target, to: target, before: "planned\n", mode: 0o644},
|
||||
"copy": {kind: structuredPatchCopy, from: target, to: destination, before: "planned\n", after: "planned\n", mode: 0o644},
|
||||
} {
|
||||
writeTestFile(t, path, "changed after planning\n")
|
||||
committed, err := applyStructuredPatchChange(workspace, change)
|
||||
if err == nil || committed || !strings.Contains(err.Error(), "changed on disk between planning and commit") {
|
||||
t.Fatalf("%s: expected a refusal, got committed=%v err=%v", name, committed, err)
|
||||
}
|
||||
if content, _ := os.ReadFile(path); string(content) != "changed after planning\n" {
|
||||
t.Fatalf("%s: file must be untouched, got %q", name, string(content))
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(root, "copy.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("%s: refused change must not create copy.txt", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnifiedPatchDeletionIsByteExact(t *testing.T) {
|
||||
deletion := func(hunks ...string) string {
|
||||
return strings.Join(append(append([]string{"--- a/gone.txt", "+++ /dev/null"}, hunks...), ""), "\n")
|
||||
}
|
||||
for name, tc := range map[string]struct{ file, patch string }{
|
||||
"trailing whitespace differs": {"current \n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"indentation differs": {" current\n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"blank line not covered": {"current\n\n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"space-only line not covered": {"current\n \n", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
"missing final newline": {"current", deletion("@@ -1 +0,0 @@", "-current")},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), tc.file)
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": tc.patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatalf("deletion must be byte-exact; %q was deleted by a non-matching hunk", tc.file)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "gone.txt")); string(content) != tc.file {
|
||||
t.Fatalf("file must be unchanged, got %q", string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("no-newline marker on the removed side matches exactly", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "current")
|
||||
patch := deletion("@@ -1 +0,0 @@", "-current", "\\ No newline at end of file")
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch}); result.Status != StatusOK {
|
||||
t.Fatalf("deletion with a matching no-newline marker should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "gone.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("file must be deleted")
|
||||
}
|
||||
})
|
||||
t.Run("CRLF file matches an LF patch of the same content", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "gone.txt"), "a\r\nb\r\n")
|
||||
if result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deletion("@@ -1,2 +0,0 @@", "-a", "-b")}); result.Status != StatusOK {
|
||||
t.Fatalf("CRLF deletion should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// git describes an empty file's creation or deletion with header lines only.
|
||||
func TestUnifiedPatchHeaderOnlyEmptyFileForms(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "empty.txt"), "")
|
||||
writeTestFile(t, filepath.Join(root, "full.txt"), "content\n")
|
||||
writeTestFile(t, filepath.Join(root, "keep.txt"), "a\n")
|
||||
deleteHeader := func(path string) string {
|
||||
return "diff --git a/" + path + " b/" + path + "\ndeleted file mode 100644\nindex e69de29..0000000\n"
|
||||
}
|
||||
// A non-empty file is not deleted by the header-only form.
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": deleteHeader("full.txt")})
|
||||
if result.Status == StatusOK || !strings.Contains(result.Output, "expects an empty file") {
|
||||
t.Fatalf("header-only deletion of a non-empty file must be refused, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "full.txt")); err != nil {
|
||||
t.Fatal("non-empty file must still exist")
|
||||
}
|
||||
// In a multi-file patch the empty-file deletion and creation are applied
|
||||
// alongside a normal hunk.
|
||||
patch := deleteHeader("empty.txt") +
|
||||
"diff --git a/blank.txt b/blank.txt\nnew file mode 100644\nindex 0000000..e69de29\n" +
|
||||
"diff --git a/keep.txt b/keep.txt\n--- a/keep.txt\n+++ b/keep.txt\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
result = NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("multi-file patch with header-only forms should apply, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "empty.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("empty file must be deleted")
|
||||
}
|
||||
if content, err := os.ReadFile(filepath.Join(root, "blank.txt")); err != nil || len(content) != 0 {
|
||||
t.Fatalf("empty file must be created, got err=%v content=%q", err, string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "keep.txt")); string(content) != "b\n" {
|
||||
t.Fatalf("hunk file = %q", string(content))
|
||||
}
|
||||
if len(result.ChangedFiles) != 3 {
|
||||
t.Fatalf("changed files = %v", result.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
// When a later change fails, the error names exactly which files were already
|
||||
// committed so the caller knows what changed and what did not.
|
||||
func TestApplyPatchOperationsReportsCommittedPrefixOnFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "first.txt"), "one\n")
|
||||
writeTestFile(t, filepath.Join(root, "second.txt"), "two\n")
|
||||
writeTestFile(t, filepath.Join(root, "third.txt"), "three\n")
|
||||
// Make second.txt change after planning so its commit is refused.
|
||||
structuredPatchBeforeCommit = func(change structuredPatchChange) {
|
||||
if change.to.relative == "second.txt" {
|
||||
writeTestFile(t, filepath.Join(root, "second.txt"), "changed\n")
|
||||
}
|
||||
}
|
||||
defer func() { structuredPatchBeforeCommit = nil }()
|
||||
patch := strings.Join([]string{
|
||||
"*** Begin Patch",
|
||||
"*** Update File: first.txt", "@@", "-one", "+ONE",
|
||||
"*** Update File: second.txt", "@@", "-two", "+TWO",
|
||||
"*** Update File: third.txt", "@@", "-three", "+THREE",
|
||||
"*** End Patch", "",
|
||||
}, "\n")
|
||||
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
|
||||
if result.Status == StatusOK {
|
||||
t.Fatal("patch must fail when a later change is refused")
|
||||
}
|
||||
for _, want := range []string{"already committed: first.txt", "remaining files are unchanged"} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("error must report the committed prefix, got: %s", result.Output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result.Output, "committed: first.txt, second.txt") || strings.Contains(result.Output, "third.txt") {
|
||||
t.Fatalf("error must not list uncommitted files as committed: %s", result.Output)
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "first.txt")); string(content) != "ONE\n" {
|
||||
t.Fatalf("first.txt must hold the committed change, got %q", string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "second.txt")); string(content) != "changed\n" {
|
||||
t.Fatalf("second.txt must be untouched by the patch, got %q", string(content))
|
||||
}
|
||||
if content, _ := os.ReadFile(filepath.Join(root, "third.txt")); string(content) != "three\n" {
|
||||
t.Fatalf("third.txt must be untouched, got %q", string(content))
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any
|
||||
options.FileTracker.Record(absolutePath, []byte(updated), newInfo)
|
||||
if updated == modelKnownContent {
|
||||
if previouslySeenWhole {
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(updated), lineCount(updated))
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(updated), trackedLineTotal(updated))
|
||||
} else {
|
||||
for _, span := range editedSpans {
|
||||
options.FileTracker.RecordSeenBytes(absolutePath, span.start, span.end, len(updated))
|
||||
|
||||
@@ -26,8 +26,8 @@ func TestReadFileToolReadsLineRanges(t *testing.T) {
|
||||
}
|
||||
for _, want := range []string{
|
||||
"File: notes.txt (lines 2-3 of 4)",
|
||||
"2 | beta",
|
||||
"3 | gamma",
|
||||
"2→beta",
|
||||
"3→gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, result.Output)
|
||||
@@ -54,7 +54,7 @@ func TestReadFileToolReadsCanonicalLineRange(t *testing.T) {
|
||||
if result.Truncated {
|
||||
t.Fatalf("canonical limit defines the requested range and must not mark it truncated: %#v", result.Meta)
|
||||
}
|
||||
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
|
||||
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
|
||||
t.Fatalf("canonical range returned the wrong lines: %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
|
||||
@@ -94,7 +94,7 @@ func TestReadFileToolMixedLegacyRangesPreferLines(t *testing.T) {
|
||||
if result.Status != StatusOK {
|
||||
t.Fatalf("mixed legacy range should recover, got %s: %s", result.Status, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
|
||||
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
|
||||
t.Fatalf("mixed legacy range did not prefer lines: %q", result.Output)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestReadFileToolCombinesLegacyStartWithCanonicalLimit(t *testing.T) {
|
||||
if result.Status != StatusOK || result.Truncated {
|
||||
t.Fatalf("mixed compatible range should be exact, got status=%s truncated=%v: %s", result.Status, result.Truncated, result.Output)
|
||||
}
|
||||
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
|
||||
if !strings.Contains(result.Output, "2→beta") || !strings.Contains(result.Output, "3→gamma") {
|
||||
t.Fatalf("mixed compatible range returned the wrong lines: %q", result.Output)
|
||||
}
|
||||
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
|
||||
@@ -955,7 +955,7 @@ func TestReadFileToolRecoversBackwardsRange(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"end_line 2 was before start_line 3", // the note
|
||||
"only line 3 was read",
|
||||
"3 | gamma",
|
||||
"3→gamma",
|
||||
} {
|
||||
if !strings.Contains(result.Output, want) {
|
||||
t.Fatalf("expected %q in output, got %q", want, result.Output)
|
||||
@@ -984,7 +984,7 @@ func TestReadFileToolValidRangeUnchangedByRecovery(t *testing.T) {
|
||||
// Byte-for-byte: header, blank separator, the exact selected lines, and no
|
||||
// recovery note or trailing content. An altered header/separator or a stray
|
||||
// out-of-range line would fail this where a substring check would not.
|
||||
const want = "File: notes.txt (lines 2-4 of 5)\n\n2 | beta\n3 | gamma\n4 | delta"
|
||||
const want = "File: notes.txt (lines 2-4 of 5)\n\n2→beta\n3→gamma\n4→delta"
|
||||
if result.Output != want {
|
||||
t.Fatalf("valid-range output changed by the recovery path:\n got: %q\nwant: %q", result.Output, want)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package tools
|
||||
|
||||
// ModelVisible reports whether a registered tool is advertised to an agent.
|
||||
// edit_file remains implemented for compatibility with existing integrations,
|
||||
// but its text-only replacement contract cannot safely disambiguate repeated
|
||||
// source fragments. Agents use apply_patch for contextual existing-file edits.
|
||||
// Every registered tool is visible. edit_file (exact search/replace with a
|
||||
// uniqueness guard, CRLF and fuzzy fallbacks) was hidden for a while in favour
|
||||
// of apply_patch; head-to-head benchmarking showed that when a model misses the
|
||||
// patch grammar it degrades to whole-file rewrites, so both edit tools are
|
||||
// advertised and the prompt steers between them.
|
||||
func ModelVisible(tool Tool) bool {
|
||||
return tool != nil && tool.Name() != "edit_file"
|
||||
return tool != nil
|
||||
}
|
||||
@@ -2,17 +2,28 @@ package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
|
||||
func TestToolSearchSurfacesBothEditTools(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
|
||||
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
|
||||
search := NewToolSearchTool(registry).(toolSearchTool)
|
||||
|
||||
eager := search.visibleEagerToolNames(nil, nil, "ask")
|
||||
if eager["edit_file"] {
|
||||
t.Fatal("tool_search must not describe edit_file as available to the model")
|
||||
if !eager["edit_file"] {
|
||||
t.Fatal("tool_search must describe edit_file as available to the model")
|
||||
}
|
||||
if !eager["apply_patch"] {
|
||||
t.Fatal("tool_search must retain apply_patch as the available edit tool")
|
||||
t.Fatal("tool_search must retain apply_patch as an available edit tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelVisibleAdvertisesEveryRegisteredTool(t *testing.T) {
|
||||
if ModelVisible(nil) {
|
||||
t.Fatal("a nil tool must not be visible")
|
||||
}
|
||||
for _, tool := range []Tool{NewScopedEditFileTool(t.TempDir(), nil), NewScopedApplyPatchTool(t.TempDir(), nil), NewScopedWriteFileTool(t.TempDir(), nil)} {
|
||||
if !ModelVisible(tool) {
|
||||
t.Fatalf("%s must be visible to the model", tool.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const readFileByteChunkMax = 64 * 1024
|
||||
|
||||
// readFileLinePrefixSeparator follows the line number on every read_file line.
|
||||
const readFileLinePrefixSeparator = "→"
|
||||
|
||||
type readFileTool struct {
|
||||
baseTool
|
||||
workspaceRoot string
|
||||
@@ -31,7 +33,7 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
return readFileTool{
|
||||
baseTool: baseTool{
|
||||
name: "read_file",
|
||||
description: "Read exact file text with line numbers. 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 for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
|
||||
parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]PropertySchema{
|
||||
@@ -221,7 +223,6 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
}
|
||||
|
||||
lastLine := startLine + selectedLines - 1
|
||||
width := len(strconv.Itoa(lastLine))
|
||||
header := fmt.Sprintf("File: %s (%d lines)", relativePath, total)
|
||||
if startLine != 1 || endLine != total || maxLines > 0 {
|
||||
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
|
||||
@@ -244,7 +245,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
budgetedOutput.WriteString("\n")
|
||||
}
|
||||
budgetedOutput.WriteString("\n")
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines); err != nil {
|
||||
return errorResult("Error reading file " + relativePath + ": " + err.Error())
|
||||
}
|
||||
if truncated {
|
||||
@@ -357,7 +358,7 @@ func renderReadFileBytes(path, relativePath string, total, requestedStart, limit
|
||||
return Result{Status: StatusOK, Output: output, Meta: map[string]string{"next_byte_offset": strconv.Itoa(end)}}, start, end
|
||||
}
|
||||
|
||||
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int, width int) error {
|
||||
func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int, selectedLines int) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -380,10 +381,11 @@ func appendReadFileRange(output *outputBudgetBuilder, path string, startLine int
|
||||
if emitted > 0 {
|
||||
output.WriteString("\n")
|
||||
}
|
||||
number := strconv.Itoa(lineNumber)
|
||||
output.WriteString(strings.Repeat(" ", width-len(number)))
|
||||
output.WriteString(number)
|
||||
output.WriteString(" | ")
|
||||
// Compact "N→" prefix: the padded " N | " form cost ~19% of every
|
||||
// read (about 1.5k tokens per 800-line file) and was re-sent on each
|
||||
// later call; no consumer parses it, and models read "N→" natively.
|
||||
output.WriteString(strconv.Itoa(lineNumber))
|
||||
output.WriteString(readFileLinePrefixSeparator)
|
||||
output.WriteString(string(trimLineBreak(raw, ended)))
|
||||
emitted++
|
||||
}
|
||||
|
||||
@@ -171,14 +171,9 @@ func selectSourceLines(content []byte, offset, limit int) sourceSelection {
|
||||
}
|
||||
|
||||
func sourceLineCount(content []byte) int {
|
||||
if len(content) == 0 {
|
||||
return 1
|
||||
}
|
||||
lines := strings.Count(string(content), "\n")
|
||||
if content[len(content)-1] != '\n' {
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
// One implementation for reader and writer: the tracker contract lives on
|
||||
// trackedLineTotal, and this must never drift from it.
|
||||
return trackedLineTotal(string(content))
|
||||
}
|
||||
|
||||
// lineCount reports the number of newline-separated lines in s (an empty string
|
||||
@@ -189,3 +184,20 @@ func lineCount(s string) int {
|
||||
}
|
||||
return strings.Count(s, "\n") + 1
|
||||
}
|
||||
|
||||
// trackedLineTotal reports a file's line count the way read_file reports it
|
||||
// to the FileTracker (newline-terminated lines, plus an unterminated last
|
||||
// line; an empty file is one line). Writers must record this same number:
|
||||
// RecordSeenRange resets every observation when the total changes, so a
|
||||
// writer that recorded lineCount (one higher for a trailing newline) made the
|
||||
// next partial read_file wipe whole-file knowledge and refuse the next edit.
|
||||
func trackedLineTotal(s string) int {
|
||||
if s == "" {
|
||||
return 1
|
||||
}
|
||||
total := strings.Count(s, "\n")
|
||||
if !strings.HasSuffix(s, "\n") {
|
||||
total++
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/pathjail"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -28,6 +30,9 @@ const (
|
||||
structuredPatchAdd structuredPatchKind = iota
|
||||
structuredPatchDelete
|
||||
structuredPatchUpdate
|
||||
// structuredPatchCopy creates movePath from path's content (plus any
|
||||
// hunks) and keeps path; produced by a unified diff's "copy from/to".
|
||||
structuredPatchCopy
|
||||
)
|
||||
|
||||
type structuredPatchOperation struct {
|
||||
@@ -37,8 +42,29 @@ type structuredPatchOperation struct {
|
||||
contents string
|
||||
chunks []structuredPatchChunk
|
||||
line int
|
||||
// eofNewline lets a unified diff's "\ No newline at end of file" marker
|
||||
// force the trailing-newline state of the result; structured patches keep
|
||||
// the file's existing state.
|
||||
eofNewline eofNewlineMode
|
||||
// verifyDelete marks a deletion that carries the expected old content
|
||||
// (a unified diff's "+++ /dev/null" hunks, or git's header-only form for
|
||||
// an empty file): the removed lines must equal the current file byte for
|
||||
// byte, otherwise the deletion is stale and refused. A structured
|
||||
// "*** Delete File" means "delete this path" and has no chunks.
|
||||
verifyDelete bool
|
||||
// oldNoNewline records a unified diff's "\ No newline at end of file"
|
||||
// on the removed side, i.e. the old content did not end with a newline.
|
||||
oldNoNewline bool
|
||||
}
|
||||
|
||||
type eofNewlineMode uint8
|
||||
|
||||
const (
|
||||
eofNewlineKeep eofNewlineMode = iota
|
||||
eofNewlinePresent
|
||||
eofNewlineAbsent
|
||||
)
|
||||
|
||||
type structuredPatchChunk struct {
|
||||
context string
|
||||
hasContext bool
|
||||
@@ -46,6 +72,11 @@ type structuredPatchChunk struct {
|
||||
new []string
|
||||
newSourceOffsets []int
|
||||
endOfFile bool
|
||||
// hint is the 0-based line the hunk is expected at (from a unified diff's
|
||||
// "@@ -a,b" range). When the expected lines match there it is used as-is;
|
||||
// otherwise the hunk is located by content like a structured hunk.
|
||||
hint int
|
||||
hasHint bool
|
||||
}
|
||||
|
||||
type structuredPatchTarget struct {
|
||||
@@ -63,8 +94,36 @@ type structuredPatchChange struct {
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
// unifiedHunkRangePattern recognises a unified-diff range header ("-12,4 +12,6",
|
||||
// optionally followed by "@@ heading") written inside a structured hunk marker.
|
||||
// The line numbers carry no information for the context-anchored grammar, so
|
||||
// the range is dropped and only an explicit heading is kept as the anchor.
|
||||
var unifiedHunkRangePattern = regexp.MustCompile(`^-\d+(?:,\d+)?\s+\+\d+(?:,\d+)?\s*(?:@@\s*(.*))?$`)
|
||||
|
||||
const structuredPatchFormatHint = ` (format: "*** Begin Patch", then "*** Update File: path" / "*** Add File: path" / "*** Delete File: path" sections whose hunks start with "@@" and use " " context, "-" removed and "+" added lines, then "*** End Patch")`
|
||||
|
||||
// structuredPatchMarker classifies a line as the "begin" or "end" marker of a
|
||||
// structured patch, or "" when it is neither. The classifier is owned by the
|
||||
// sandbox package so the boundary check and the tool accept exactly the same
|
||||
// spellings; a strict byte-equal match here once made every patch from some
|
||||
// models fail on line 1 and pushed them into whole-file rewrites.
|
||||
func structuredPatchMarker(line string) string {
|
||||
return sandbox.StructuredPatchMarker(line)
|
||||
}
|
||||
|
||||
// structuredHunkAnchor normalises the text after a hunk's "@@ " marker. A
|
||||
// unified-diff range is dropped (its optional heading survives as the anchor);
|
||||
// anything else is used verbatim.
|
||||
func structuredHunkAnchor(context string) string {
|
||||
context = strings.TrimSpace(context)
|
||||
if match := unifiedHunkRangePattern.FindStringSubmatch(context); match != nil {
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func isStructuredPatch(patch string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
|
||||
return sandbox.IsStructuredPatch(patch)
|
||||
}
|
||||
|
||||
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
|
||||
@@ -72,6 +131,14 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
}
|
||||
return applyPatchOperations(applyRoot, relativeRoot, operations, options)
|
||||
}
|
||||
|
||||
// applyPatchOperations applies parsed operations (from either patch format)
|
||||
// through an opened workspace root: every stat, read, create and write is
|
||||
// descriptor-relative and refuses to follow a link out of the root, so there
|
||||
// is no pathname check-to-use window between validation and write.
|
||||
func applyPatchOperations(applyRoot, relativeRoot string, operations []structuredPatchOperation, options RunOptions) Result {
|
||||
workspace, err := os.OpenRoot(applyRoot)
|
||||
if err != nil {
|
||||
return errorResult("Error applying patch: " + err.Error())
|
||||
@@ -84,7 +151,7 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str
|
||||
wholeBefore := make(map[string]bool, len(changes))
|
||||
if options.FileTracker != nil {
|
||||
for _, change := range changes {
|
||||
if change.kind == structuredPatchUpdate {
|
||||
if change.kind == structuredPatchUpdate || change.kind == structuredPatchCopy {
|
||||
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +170,10 @@ func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch str
|
||||
wasWhole := wholeBefore[change.from.absolute]
|
||||
options.FileTracker.Forget(change.from.absolute)
|
||||
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
|
||||
case structuredPatchCopy:
|
||||
// The source is untouched; the destination inherits only what the
|
||||
// model had actually seen of the source.
|
||||
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, wholeBefore[change.from.absolute])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,11 +193,11 @@ func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
|
||||
return nil, fmt.Errorf("structured patch is empty")
|
||||
}
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
|
||||
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
|
||||
if structuredPatchMarker(lines[0]) != "begin" {
|
||||
return nil, fmt.Errorf("the first line of a structured patch must be %q%s", structuredPatchBegin, structuredPatchFormatHint)
|
||||
}
|
||||
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
|
||||
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
|
||||
if structuredPatchMarker(lines[len(lines)-1]) != "end" {
|
||||
return nil, fmt.Errorf("the last line of a structured patch must be %q%s", structuredPatchEnd, structuredPatchFormatHint)
|
||||
}
|
||||
|
||||
var operations []structuredPatchOperation
|
||||
@@ -191,8 +262,12 @@ func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
|
||||
}
|
||||
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
|
||||
chunk := structuredPatchChunk{}
|
||||
rest := ""
|
||||
if trimmed != "@@" {
|
||||
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
|
||||
rest = strings.TrimPrefix(trimmed, structuredHunkContext)
|
||||
}
|
||||
if anchor := structuredHunkAnchor(rest); anchor != "" {
|
||||
chunk.context = anchor
|
||||
chunk.hasContext = true
|
||||
}
|
||||
op.chunks = append(op.chunks, chunk)
|
||||
@@ -248,7 +323,7 @@ func structuredPatchPath(header, prefix string, line int) (string, error) {
|
||||
}
|
||||
|
||||
func isStructuredPatchHeader(line string) bool {
|
||||
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
|
||||
return structuredPatchMarker(line) == "end" || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
|
||||
}
|
||||
|
||||
func structuredPatchLineError(line int, message string) error {
|
||||
@@ -318,7 +393,7 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
|
||||
return nil, err
|
||||
}
|
||||
change.after = operation.contents
|
||||
case structuredPatchDelete, structuredPatchUpdate:
|
||||
case structuredPatchDelete, structuredPatchUpdate, structuredPatchCopy:
|
||||
info, err := root.Stat(from.relative)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
|
||||
@@ -332,12 +407,20 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
|
||||
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
|
||||
}
|
||||
change.before = string(content)
|
||||
if operation.kind == structuredPatchUpdate {
|
||||
if operation.kind == structuredPatchDelete && operation.verifyDelete {
|
||||
if err := verifyUnifiedDeletion(change.before, from.relative, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if operation.kind == structuredPatchUpdate || operation.kind == structuredPatchCopy {
|
||||
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
change.after = updated
|
||||
change.after = applyEOFNewline(updated, operation.eofNewline)
|
||||
if operation.kind == structuredPatchCopy && from.absolute == to.absolute {
|
||||
return nil, fmt.Errorf("cannot copy %s onto itself", from.relative)
|
||||
}
|
||||
if from.absolute != to.absolute {
|
||||
if _, err := root.Lstat(to.relative); err == nil {
|
||||
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
|
||||
@@ -353,10 +436,14 @@ func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, t
|
||||
}
|
||||
|
||||
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
|
||||
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
|
||||
// Relative traversal is rejected outright. An absolute path is allowed when
|
||||
// it resolves inside the apply root (models routinely echo the absolute path
|
||||
// they were shown by read_file); resolveWorkspaceTargetPath rejects anything
|
||||
// that lands outside.
|
||||
if path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
|
||||
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
|
||||
}
|
||||
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
|
||||
absolute, relative, err := resolveWorkspaceTargetPath(root, normalizePatchPathForRoot(root, path))
|
||||
if err != nil {
|
||||
return structuredPatchTarget{}, err
|
||||
}
|
||||
@@ -389,9 +476,15 @@ func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchCh
|
||||
if len(chunk.old) == 0 {
|
||||
// A context anchor identifies the line immediately before a pure
|
||||
// insertion. Only an explicit end-of-file hunk (or an unanchored
|
||||
// insertion) belongs at the file end.
|
||||
// insertion) belongs at the file end. A unified diff names the
|
||||
// insertion point in its range instead; a zero-context hunk (as
|
||||
// from `git diff -U0`) has nothing to verify the position against,
|
||||
// so it is position-trusting by design — the same semantics git
|
||||
// apply uses without fuzz. Hunks that carry context are verified.
|
||||
start := lineIndex
|
||||
if chunk.endOfFile || !chunk.hasContext {
|
||||
if chunk.hasHint {
|
||||
start = max(chunk.hint, lineIndex)
|
||||
} else if chunk.endOfFile || !chunk.hasContext {
|
||||
start = len(lines)
|
||||
}
|
||||
if start > len(lines) {
|
||||
@@ -405,7 +498,13 @@ func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchCh
|
||||
lineIndex = start
|
||||
continue
|
||||
}
|
||||
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
|
||||
var index int
|
||||
ambiguous := false
|
||||
if chunk.hasHint && chunk.hint >= lineIndex && sequenceMatchesAt(lines, chunk.old, chunk.hint) {
|
||||
index = chunk.hint
|
||||
} else {
|
||||
index, ambiguous = findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
|
||||
}
|
||||
if ambiguous {
|
||||
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
|
||||
}
|
||||
@@ -440,6 +539,64 @@ func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchCh
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// verifyUnifiedDeletion checks, byte for byte, that the lines a unified
|
||||
// deletion removes are exactly the file's current content. A deletion is not
|
||||
// recoverable, so none of the whitespace tolerance used to locate update
|
||||
// hunks applies here; only the file's own line-ending style is normalised.
|
||||
// With no hunks (git's header-only form) the file must be empty.
|
||||
func verifyUnifiedDeletion(current, path string, operation structuredPatchOperation) error {
|
||||
var removed []string
|
||||
for _, chunk := range operation.chunks {
|
||||
if len(chunk.new) > 0 {
|
||||
return fmt.Errorf("deletion of %s must not add lines", path)
|
||||
}
|
||||
removed = append(removed, chunk.old...)
|
||||
}
|
||||
expected := strings.Join(removed, "\n")
|
||||
if len(removed) > 0 && !operation.oldNoNewline {
|
||||
expected += "\n"
|
||||
}
|
||||
actual := current
|
||||
if structuredPatchLineEnding(current) == "\r\n" {
|
||||
actual = strings.ReplaceAll(current, "\r\n", "\n")
|
||||
}
|
||||
if actual != expected {
|
||||
if len(removed) == 0 {
|
||||
return fmt.Errorf("deletion of %s expects an empty file but it has content; include the removed lines in the patch", path)
|
||||
}
|
||||
return fmt.Errorf("deletion of %s does not match its current content; the removed lines must equal the whole file", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sequenceMatchesAt reports whether wanted appears verbatim at lines[start:].
|
||||
func sequenceMatchesAt(lines, wanted []string, start int) bool {
|
||||
if start < 0 || start+len(wanted) > len(lines) {
|
||||
return false
|
||||
}
|
||||
for offset, line := range wanted {
|
||||
if lines[start+offset] != line {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyEOFNewline forces or strips a single trailing line ending when a
|
||||
// unified diff's end-of-file marker asked for it.
|
||||
func applyEOFNewline(content string, mode eofNewlineMode) string {
|
||||
switch mode {
|
||||
case eofNewlinePresent:
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
return content + structuredPatchLineEnding(content)
|
||||
}
|
||||
case eofNewlineAbsent:
|
||||
content = strings.TrimSuffix(content, "\n")
|
||||
content = strings.TrimSuffix(content, "\r")
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
|
||||
if len(chunk.newSourceOffsets) != len(chunk.new) {
|
||||
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
|
||||
@@ -516,14 +673,19 @@ func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bo
|
||||
}
|
||||
|
||||
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
|
||||
mutated := false
|
||||
// committed lists, in order, the paths whose change reached disk before a
|
||||
// later change failed, so the caller (and the model) knows exactly which
|
||||
// files now hold the patched content and which were never touched.
|
||||
var committed []string
|
||||
for _, change := range changes {
|
||||
committed, err := applyStructuredPatchChange(root, change)
|
||||
mutated = mutated || committed
|
||||
done, err := applyStructuredPatchChange(root, change)
|
||||
if done {
|
||||
committed = append(committed, structuredPatchChangePaths(change)...)
|
||||
}
|
||||
if err != nil {
|
||||
forgetStructuredPatchFiles(tracker, changes)
|
||||
if mutated {
|
||||
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
|
||||
if len(committed) > 0 {
|
||||
return fmt.Errorf("%w; patch was partially applied — already committed: %s; the remaining files are unchanged; re-read the committed files before retrying", err, strings.Join(committed, ", "))
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -531,6 +693,19 @@ func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange,
|
||||
return nil
|
||||
}
|
||||
|
||||
// structuredPatchChangePaths names the workspace-relative paths a committed
|
||||
// change touched: the destination, plus the source of a move or copy.
|
||||
func structuredPatchChangePaths(change structuredPatchChange) []string {
|
||||
if change.kind == structuredPatchDelete {
|
||||
return []string{change.from.relative}
|
||||
}
|
||||
paths := []string{change.to.relative}
|
||||
if change.from.absolute != change.to.absolute && change.from.relative != "" {
|
||||
paths = append([]string{change.from.relative}, paths...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
|
||||
if tracker == nil {
|
||||
return
|
||||
@@ -543,14 +718,35 @@ func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchC
|
||||
}
|
||||
}
|
||||
|
||||
// structuredPatchBeforeCommit, when set, runs before each change's pre-commit
|
||||
// recheck. Tests use it to alter the filesystem between planning and commit
|
||||
// deterministically; it is nil in production.
|
||||
var structuredPatchBeforeCommit func(change structuredPatchChange)
|
||||
|
||||
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
|
||||
if structuredPatchBeforeCommit != nil {
|
||||
structuredPatchBeforeCommit(change)
|
||||
}
|
||||
// The planner validated the source against the bytes it read; re-read
|
||||
// through the same root immediately before committing so a change made
|
||||
// by another process in between is refused rather than overwritten or
|
||||
// removed.
|
||||
if change.kind != structuredPatchAdd {
|
||||
current, err := root.ReadFile(change.from.relative)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("re-reading %s before commit: %w", change.from.relative, err)
|
||||
}
|
||||
if string(current) != change.before {
|
||||
return false, fmt.Errorf("%s changed on disk between planning and commit; re-read it and retry", change.from.relative)
|
||||
}
|
||||
}
|
||||
switch change.kind {
|
||||
case structuredPatchDelete:
|
||||
if err := root.Remove(change.from.relative); err != nil {
|
||||
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
|
||||
}
|
||||
return true, nil
|
||||
case structuredPatchAdd:
|
||||
case structuredPatchAdd, structuredPatchCopy:
|
||||
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
|
||||
case structuredPatchUpdate:
|
||||
moving := change.from.absolute != change.to.absolute
|
||||
@@ -706,7 +902,7 @@ func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, s
|
||||
info, _ := os.Stat(absolute)
|
||||
tracker.Record(absolute, content, info)
|
||||
if seenWhole {
|
||||
lines := lineCount(string(content))
|
||||
lines := trackedLineTotal(string(content))
|
||||
tracker.RecordSeenRange(absolute, 1, lines, lines)
|
||||
}
|
||||
if created {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrackedLineTotalMatchesReadFileStats(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, content := range map[string]string{"trailing": "a\nb\nc\n", "unterminated": "a\nb\nc", "single": "x\n", "empty": "", "crlf": "a\r\nb\r\n"} {
|
||||
path := filepath.Join(root, name+".txt")
|
||||
writeTestFile(t, path, content)
|
||||
stats, err := scanReadFileStats(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := trackedLineTotal(content); got != stats.lines {
|
||||
t.Fatalf("%s: trackedLineTotal = %d, read_file reports %d", name, got, stats.lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A whole-file read, an edit, then a partial re-read must keep the file "seen
|
||||
// whole": the writer and reader now agree on the line total, so the partial
|
||||
// read accumulates instead of resetting and a later edit far from the re-read
|
||||
// window is not refused.
|
||||
func TestEditThenPartialReadKeepsWholeFileObservation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "notes.txt")
|
||||
content := ""
|
||||
for i := 1; i <= 40; i++ {
|
||||
content += fmt.Sprintf("line %02d\n", i)
|
||||
}
|
||||
writeTestFile(t, path, content)
|
||||
tracker := NewFileTracker()
|
||||
registry := NewRegistry()
|
||||
registry.Register(NewScopedReadFileTool(root, nil))
|
||||
registry.Register(NewScopedEditFileTool(root, nil))
|
||||
opts := RunOptions{PermissionGranted: true, FileTracker: tracker}
|
||||
|
||||
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("read: %s", r.Output)
|
||||
}
|
||||
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 01\nline 02\n", "new_string": "line 01\nline two\n"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("first edit: %s", r.Output)
|
||||
}
|
||||
if r := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "notes.txt", "limit": 5}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("partial read: %s", r.Output)
|
||||
}
|
||||
absolute, _ := filepath.EvalSymlinks(path)
|
||||
if !tracker.SeenWhole(absolute) {
|
||||
t.Fatal("partial re-read of an unchanged file must not discard whole-file knowledge")
|
||||
}
|
||||
if r := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{"path": "notes.txt", "old_string": "line 39\nline 40\n", "new_string": "line 39\nline forty\n"}, opts); r.Status != StatusOK {
|
||||
t.Fatalf("edit far from the re-read window must succeed: %s", r.Output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseUnifiedPatch converts a unified diff into the same operations a
|
||||
// structured patch produces, so both formats are applied by Zero itself
|
||||
// through an opened workspace root (descriptor-relative, no-follow writes)
|
||||
// instead of handing pathnames to git apply after validation.
|
||||
//
|
||||
// Supported: file modifications, creations (--- /dev/null) and deletions
|
||||
// (+++ /dev/null, verified against the content the hunks expect to remove),
|
||||
// multiple hunks per file, "\ No newline at end of file"
|
||||
// markers, a/ and b/ prefixes, git C-quoted paths, CRLF patches, and git's
|
||||
// "rename from/to" and "copy from/to" headers (with or without hunks). Headers
|
||||
// git emits around a hunk (diff --git, index, mode lines) are skipped; binary
|
||||
// patches are rejected.
|
||||
func parseUnifiedPatch(patch string) ([]structuredPatchOperation, error) {
|
||||
normalized := strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
// The element after a final "\n" is the line terminator, not an empty
|
||||
// context line; counting it would let a truncated hunk look complete.
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
var operations []structuredPatchOperation
|
||||
var current *structuredPatchOperation
|
||||
var chunk *structuredPatchChunk
|
||||
var added []string // collected "+" lines for a file creation
|
||||
oldPath, newPath := "", ""
|
||||
pendingFrom, pendingKind := "", structuredPatchUpdate
|
||||
// git's header-only forms: "deleted file mode" / "new file mode" after a
|
||||
// "diff --git" line with no ---/+++ pair describe an empty file.
|
||||
diffPath, headerOnly := "", byte(0) // headerOnly is 'd' (deleted) or 'n' (new)
|
||||
oldRemaining, newRemaining := 0, 0
|
||||
inHunk := false
|
||||
lastSide := byte(0) // '+' or '-' for the most recent content line
|
||||
|
||||
finish := func() error {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
switch current.kind {
|
||||
case structuredPatchAdd:
|
||||
if len(added) > 0 {
|
||||
current.contents = strings.Join(added, "\n")
|
||||
if current.eofNewline != eofNewlineAbsent {
|
||||
current.contents += "\n"
|
||||
}
|
||||
}
|
||||
case structuredPatchUpdate, structuredPatchCopy:
|
||||
if !allStructuredPatchChunksHaveContent(current.chunks) {
|
||||
return fmt.Errorf("unified diff for %s has an empty hunk", current.path)
|
||||
}
|
||||
if len(current.chunks) == 0 && current.movePath == "" {
|
||||
return fmt.Errorf("unified diff for %s has no hunk lines", current.path)
|
||||
}
|
||||
case structuredPatchDelete:
|
||||
if len(current.chunks) == 0 || !allStructuredPatchChunksHaveContent(current.chunks) {
|
||||
return fmt.Errorf("unified deletion of %s must include the hunk with the content being removed", current.path)
|
||||
}
|
||||
}
|
||||
operations = append(operations, *current)
|
||||
current, chunk, added = nil, nil, nil
|
||||
return nil
|
||||
}
|
||||
flushHeaderOnly := func(line int) error {
|
||||
if headerOnly == 0 {
|
||||
return nil
|
||||
}
|
||||
if diffPath == "" {
|
||||
return fmt.Errorf("invalid unified diff at line %d: file mode header without a diff --git path", line)
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
switch headerOnly {
|
||||
case 'd':
|
||||
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: diffPath, line: line, verifyDelete: true})
|
||||
case 'n':
|
||||
operations = append(operations, structuredPatchOperation{kind: structuredPatchAdd, path: diffPath, line: line})
|
||||
}
|
||||
headerOnly, diffPath = 0, ""
|
||||
return nil
|
||||
}
|
||||
startFile := func(line int) error {
|
||||
// A ---/+++ pair means the file has hunks; it is not header-only.
|
||||
headerOnly, diffPath = 0, ""
|
||||
if oldPath == "" || newPath == "" {
|
||||
return fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", line)
|
||||
}
|
||||
// A ---/+++ pair after a rename/copy header names the same files; keep
|
||||
// accumulating that operation's hunks instead of starting a new one.
|
||||
if current != nil && current.movePath != "" && current.path == oldPath && (current.movePath == newPath || oldPath == newPath) {
|
||||
return nil
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
op := structuredPatchOperation{line: line}
|
||||
switch {
|
||||
case oldPath == "/dev/null" && newPath == "/dev/null":
|
||||
return fmt.Errorf("invalid unified diff at line %d: both sides are /dev/null", line)
|
||||
case oldPath == "/dev/null":
|
||||
op.kind, op.path = structuredPatchAdd, newPath
|
||||
case newPath == "/dev/null":
|
||||
// A unified deletion states the content it expects to remove; keep
|
||||
// its hunks so the planner verifies them before deleting.
|
||||
op.kind, op.path, op.verifyDelete = structuredPatchDelete, oldPath, true
|
||||
default:
|
||||
op.kind, op.path = structuredPatchUpdate, oldPath
|
||||
if newPath != oldPath {
|
||||
op.movePath = newPath
|
||||
}
|
||||
}
|
||||
current = &op
|
||||
return nil
|
||||
}
|
||||
|
||||
for index, raw := range lines {
|
||||
lineNumber := index + 1
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
// A "--- " line followed by "+++ " and then a "@@" hunk header is
|
||||
// the next file's header pair, not a removed "-- x" line followed by
|
||||
// an added "++ y" line: an over-declared count would otherwise
|
||||
// swallow both and the hunk would later fail against the wrong
|
||||
// file. The "@@" requirement keeps a genuine adjacent pair intact.
|
||||
if strings.HasPrefix(raw, "--- ") && index+2 < len(lines) && strings.HasPrefix(lines[index+1], "+++ ") && strings.HasPrefix(lines[index+2], "@@") {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "\\"):
|
||||
// "\ No newline at end of file" qualifies the previous line's side.
|
||||
if current != nil && lastSide == '+' {
|
||||
current.eofNewline = eofNewlineAbsent
|
||||
} else if current != nil && lastSide == '-' {
|
||||
current.oldNoNewline = true
|
||||
if current.eofNewline == eofNewlineKeep {
|
||||
current.eofNewline = eofNewlinePresent
|
||||
}
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(raw, "-"):
|
||||
oldRemaining--
|
||||
lastSide = '-'
|
||||
if chunk != nil {
|
||||
chunk.old = append(chunk.old, raw[1:])
|
||||
}
|
||||
case strings.HasPrefix(raw, "+"):
|
||||
newRemaining--
|
||||
lastSide = '+'
|
||||
if current != nil && current.kind == structuredPatchAdd {
|
||||
added = append(added, raw[1:])
|
||||
} else if chunk != nil {
|
||||
chunk.new = append(chunk.new, raw[1:])
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
|
||||
}
|
||||
default:
|
||||
content := raw
|
||||
if strings.HasPrefix(raw, " ") {
|
||||
content = raw[1:]
|
||||
} else if raw != "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk lines must start with ' ', '+', '-' or '\\'", lineNumber)
|
||||
}
|
||||
oldRemaining--
|
||||
newRemaining--
|
||||
lastSide = ' '
|
||||
if chunk != nil {
|
||||
offset := len(chunk.old)
|
||||
chunk.old = append(chunk.old, content)
|
||||
chunk.new = append(chunk.new, content)
|
||||
chunk.newSourceOffsets = append(chunk.newSourceOffsets, offset)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
inHunk = false
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if oldRemaining > 0 || newRemaining > 0 {
|
||||
// The hunk ended before its declared counts were consumed. Report
|
||||
// it here rather than absorbing the next file's ---/+++ headers as
|
||||
// "-"/"+" content and failing later against the wrong file.
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", lineNumber)
|
||||
}
|
||||
switch {
|
||||
case trimmed == "":
|
||||
continue
|
||||
case strings.HasPrefix(raw, "\\"):
|
||||
// "\ No newline at end of file" directly after a hunk's last line.
|
||||
if current != nil && lastSide == '+' {
|
||||
current.eofNewline = eofNewlineAbsent
|
||||
} else if current != nil && lastSide == '-' {
|
||||
current.oldNoNewline = true
|
||||
if current.eofNewline == eofNewlineKeep {
|
||||
current.eofNewline = eofNewlinePresent
|
||||
}
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(raw, "diff --git "):
|
||||
if err := flushHeaderOnly(lineNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diffPath = diffGitNewPath(raw)
|
||||
continue
|
||||
case strings.HasPrefix(raw, "deleted file mode "):
|
||||
headerOnly = 'd'
|
||||
continue
|
||||
case strings.HasPrefix(raw, "new file mode "):
|
||||
headerOnly = 'n'
|
||||
continue
|
||||
case strings.HasPrefix(raw, "index "), strings.HasPrefix(raw, "old mode "), strings.HasPrefix(raw, "new mode "):
|
||||
continue
|
||||
case strings.HasPrefix(raw, "similarity index "), strings.HasPrefix(raw, "dissimilarity index "):
|
||||
continue
|
||||
case strings.HasPrefix(raw, "rename from "), strings.HasPrefix(raw, "copy from "):
|
||||
pendingFrom = strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename from "), "copy from ")))
|
||||
pendingKind = structuredPatchUpdate
|
||||
if strings.HasPrefix(raw, "copy from ") {
|
||||
pendingKind = structuredPatchCopy
|
||||
}
|
||||
if pendingFrom == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: missing source path", lineNumber)
|
||||
}
|
||||
case strings.HasPrefix(raw, "rename to "), strings.HasPrefix(raw, "copy to "):
|
||||
to := strings.TrimSpace(unquoteGitPath(strings.TrimPrefix(strings.TrimPrefix(raw, "rename to "), "copy to ")))
|
||||
if pendingFrom == "" || to == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: rename/copy destination without a source", lineNumber)
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current = &structuredPatchOperation{kind: pendingKind, path: pendingFrom, movePath: to, line: lineNumber}
|
||||
oldPath, newPath, pendingFrom = pendingFrom, to, ""
|
||||
case strings.HasPrefix(raw, "Binary files "), strings.HasPrefix(raw, "GIT binary patch"):
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: binary patches are not supported", lineNumber)
|
||||
case strings.HasPrefix(raw, "--- "):
|
||||
oldPath = stripPatchPrefix(patchFileHeaderPath(raw))
|
||||
newPath = ""
|
||||
if oldPath == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in --- header", lineNumber)
|
||||
}
|
||||
case strings.HasPrefix(raw, "+++ "):
|
||||
newPath = stripPatchPrefix(patchFileHeaderPath(raw))
|
||||
if newPath == "" {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: missing path in +++ header", lineNumber)
|
||||
}
|
||||
if err := startFile(lineNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case strings.HasPrefix(raw, "@@"):
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk before a ---/+++ header pair", lineNumber)
|
||||
}
|
||||
oldStart, oldCount, newCount, ok := parseHunkRange(raw)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: malformed hunk header", lineNumber)
|
||||
}
|
||||
// "\ No newline at end of file" describes the end of the file, so it
|
||||
// may only follow a file's final hunk; a marker before another hunk
|
||||
// would otherwise leak into that later hunk's result.
|
||||
if current.eofNewline != eofNewlineKeep {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: \"\\ No newline at end of file\" must follow the last hunk of a file", lineNumber)
|
||||
}
|
||||
oldRemaining, newRemaining = oldCount, newCount
|
||||
inHunk = oldRemaining > 0 || newRemaining > 0
|
||||
lastSide = 0
|
||||
if current.kind != structuredPatchAdd {
|
||||
next := structuredPatchChunk{hasHint: true, hint: oldStart - 1}
|
||||
if oldCount == 0 {
|
||||
// A pure insertion's range names the line after which the
|
||||
// new lines go, so the insertion index is oldStart itself.
|
||||
next.hint = oldStart
|
||||
}
|
||||
if next.hint < 0 {
|
||||
next.hint = 0
|
||||
}
|
||||
current.chunks = append(current.chunks, next)
|
||||
chunk = ¤t.chunks[len(current.chunks)-1]
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: unexpected %q", lineNumber, trimmed)
|
||||
}
|
||||
}
|
||||
// Strict at end of input on purpose: a hunk cut short (typically a model
|
||||
// response that stopped mid-patch) would otherwise apply as a partial
|
||||
// change — removals without their replacements — which is worse than a
|
||||
// clear error and a retry. Miscounted ranges with complete content are
|
||||
// still tolerated at every other boundary.
|
||||
if inHunk && (oldRemaining > 0 || newRemaining > 0) {
|
||||
return nil, fmt.Errorf("invalid unified diff at line %d: hunk ended before its declared line counts", len(lines))
|
||||
}
|
||||
if err := flushHeaderOnly(len(lines)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := finish(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(operations) == 0 {
|
||||
return nil, fmt.Errorf("unified diff contains no file changes")
|
||||
}
|
||||
return operations, nil
|
||||
}
|
||||
|
||||
// diffGitNewPath returns the post-image path named by a "diff --git a/X b/Y"
|
||||
// line, handling git's C-quoted form; "" when it cannot be determined.
|
||||
func diffGitNewPath(line string) string {
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(line, "diff --git "))
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
// Two quoted tokens: skip the first, unquote the second.
|
||||
end := strings.Index(rest[1:], "\"")
|
||||
for end > 0 && rest[end] == '\\' {
|
||||
next := strings.Index(rest[end+2:], "\"")
|
||||
if next < 0 {
|
||||
return ""
|
||||
}
|
||||
end += next + 2
|
||||
}
|
||||
if end < 0 || end+2 > len(rest) {
|
||||
return ""
|
||||
}
|
||||
rest = strings.TrimSpace(rest[end+2:])
|
||||
return stripPatchPrefix(unquoteGitPath(rest))
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 2 {
|
||||
return ""
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
if strings.HasPrefix(last, "\"") {
|
||||
return stripPatchPrefix(unquoteGitPath(last))
|
||||
}
|
||||
return stripPatchPrefix(last)
|
||||
}
|
||||
|
||||
// parseHunkRange reads "@@ -a[,b] +c[,d] @@" and returns a, b and d; a missing
|
||||
// count means 1 per unified-diff convention.
|
||||
func parseHunkRange(line string) (oldStart, oldCount, newCount int, ok bool) {
|
||||
_, rest, found := strings.Cut(line, "@@")
|
||||
if !found {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
rangeSection := rest
|
||||
if before, _, found := strings.Cut(rest, "@@"); found {
|
||||
rangeSection = before
|
||||
}
|
||||
fields := strings.Fields(rangeSection)
|
||||
if len(fields) != 2 || !strings.HasPrefix(fields[0], "-") || !strings.HasPrefix(fields[1], "+") {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
parse := func(spec string) (int, int, bool) {
|
||||
startText, countText, hasCount := strings.Cut(spec, ",")
|
||||
start, err := parseNonNegativeInt(startText)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
count := 1
|
||||
if hasCount {
|
||||
if count, err = parseNonNegativeInt(countText); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
return start, count, true
|
||||
}
|
||||
oldStart, oldCount, ok = parse(fields[0][1:])
|
||||
if !ok {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
_, newCount, ok = parse(fields[1][1:])
|
||||
if !ok {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return oldStart, oldCount, newCount, true
|
||||
}
|
||||
|
||||
func parseNonNegativeInt(text string) (int, error) {
|
||||
if text == "" {
|
||||
return 0, fmt.Errorf("empty number")
|
||||
}
|
||||
value := 0
|
||||
for _, r := range text {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, fmt.Errorf("not a number: %q", text)
|
||||
}
|
||||
value = value*10 + int(r-'0')
|
||||
if value > 1<<30 {
|
||||
return 0, fmt.Errorf("number too large: %q", text)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (tool writeFileTool) RunWithOptions(ctx context.Context, args map[string]an
|
||||
newInfo, _ := os.Stat(absolutePath)
|
||||
options.FileTracker.Record(absolutePath, []byte(content), newInfo)
|
||||
if content == modelKnownContent {
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, lineCount(content), lineCount(content))
|
||||
options.FileTracker.RecordSeenRange(absolutePath, 1, trackedLineTotal(content), trackedLineTotal(content))
|
||||
}
|
||||
if !existed {
|
||||
options.FileTracker.RecordCreated(absolutePath)
|
||||
|
||||
@@ -866,7 +866,9 @@ func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
|
||||
// A move whose source vanishes after planning is refused by the pre-commit
|
||||
// recheck before the destination is published, so nothing is left half-done.
|
||||
func TestStructuredPatchMoveWithMissingSourceIsRefusedBeforePublishing(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
workspace, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
@@ -875,22 +877,35 @@ func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
|
||||
defer workspace.Close()
|
||||
sourcePath := filepath.Join(root, "source.txt")
|
||||
destinationPath := filepath.Join(root, "destination.txt")
|
||||
writeTestFile(t, sourcePath, "removed source\n")
|
||||
change := structuredPatchChange{
|
||||
kind: structuredPatchUpdate,
|
||||
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
|
||||
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
|
||||
before: "removed source\n", after: "moved content\n", mode: 0o644,
|
||||
}
|
||||
// The source exists at planning time and disappears just before commit.
|
||||
removed := false
|
||||
structuredPatchBeforeCommit = func(structuredPatchChange) {
|
||||
if err := os.Remove(sourcePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
removed = true
|
||||
}
|
||||
defer func() { structuredPatchBeforeCommit = nil }()
|
||||
|
||||
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("move with a missing source = %v, want partial-application error", err)
|
||||
if !removed {
|
||||
t.Fatal("pre-commit hook did not run")
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "before commit") || strings.Contains(err.Error(), "partially applied") {
|
||||
t.Fatalf("move with a missing source = %v, want a pre-commit refusal with nothing applied", err)
|
||||
}
|
||||
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed move recreated a missing source: %v", statErr)
|
||||
}
|
||||
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
|
||||
t.Fatalf("published move destination = %q", got)
|
||||
if _, statErr := os.Stat(destinationPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("refused move must not publish the destination: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
@@ -541,6 +542,7 @@ type model struct {
|
||||
recapIdleArmed bool
|
||||
recapIdleRunID int
|
||||
idleRecap string
|
||||
compactionModel string // preferences.compactionModel (see providers.CompactionModelID)
|
||||
modelPickerLoading bool
|
||||
modelPickerLoadingProviderID string
|
||||
modelPickerLoadError string
|
||||
@@ -963,6 +965,7 @@ 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,
|
||||
@@ -5422,6 +5425,18 @@ 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,
|
||||
|
||||
@@ -2439,7 +2439,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowUser, text: "run it"},
|
||||
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
|
||||
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n1→print('x')"},
|
||||
)
|
||||
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
|
||||
toolIdx := -1
|
||||
|
||||
+16
-12
@@ -22,18 +22,22 @@ 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
|
||||
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
|
||||
Provider zeroruntime.Provider
|
||||
NewProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
ProbeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
|
||||
@@ -1075,8 +1075,8 @@ func TestEditedDiffCardHandlesBareBlankHunkContext(t *testing.T) {
|
||||
|
||||
func TestReadCardBodyShowsExploredSummary(t *testing.T) {
|
||||
m := limeTestModel()
|
||||
// Mirrors the real read_file output shape: "<right-aligned N> | <text>".
|
||||
detail := "File: internal/agent/loop.go\n\n 12 | func Run() {\n 13 | }\n"
|
||||
// Mirrors the real read_file output shape: "N→<text>".
|
||||
detail := "File: internal/agent/loop.go\n\n12→func Run() {\n13→}\n"
|
||||
row := transcriptRow{kind: rowToolResult, id: "call_1", tool: "read_file", status: tools.StatusOK, detail: detail}
|
||||
rc := buildRowContext([]transcriptRow{{kind: rowToolCall, id: "call_1", tool: "read_file", detail: "internal/agent/loop.go"}})
|
||||
got := plainRender(t, m.renderRow(row, 80, rc))
|
||||
@@ -1097,7 +1097,7 @@ func TestReadCardBodyShowsExploredSummary(t *testing.T) {
|
||||
|
||||
row.expanded = true
|
||||
expanded := plainRender(t, m.renderRow(row, 80, rc))
|
||||
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go", "func Run()", "13 |"} {
|
||||
for _, want := range []string{"Explored", "└ Read", "internal/agent/loop.go", "func Run()", "13→"} {
|
||||
if !strings.Contains(expanded, want) {
|
||||
t.Fatalf("expanded read card = %q, missing %q", expanded, want)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestDefaultToolBodyRegistrySelectsCoreRenderers(t *testing.T) {
|
||||
{
|
||||
name: "read_file",
|
||||
hint: "README.md",
|
||||
detail: "File: README.md\n\n 7 | # Zero",
|
||||
detail: "File: README.md\n\n7→# Zero",
|
||||
want: []string{"Read", "README.md"},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user