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