From 46a1c16869228f707fb34d94d4b6538e19c74f07 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:52:32 -0400 Subject: [PATCH 1/5] fix(carried): resolve auxiliary model flags through the profile --- cmd/codeaf/carried.go | 19 ++++ cmd/codeaf/carried_child_test.go | 4 + cmd/codeaf/carried_model_flags_test.go | 135 +++++++++++++++++++++++++ internal/delegate/delegate.go | 10 ++ internal/delegate/model_flags_test.go | 20 ++++ internal/praf/praf.go | 15 +-- internal/secaf/secaf.go | 15 +-- 7 files changed, 204 insertions(+), 14 deletions(-) create mode 100644 cmd/codeaf/carried_model_flags_test.go create mode 100644 internal/delegate/model_flags_test.go diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go index e04d20c3b6..821540366b 100644 --- a/cmd/codeaf/carried.go +++ b/cmd/codeaf/carried.go @@ -309,6 +309,25 @@ func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { } inv.Line = carriedResolvedModel(inv.Line, modelFlag, resolved) } + // AN AUXILIARY MODEL IS RESOLVED BY THE SAME DOOR. Leaving a light + // model as typed sent an unmarked floating alias where the main model + // had already been replaced by its catalog spelling. + for _, flag := range inv.Program.AuxiliaryModelFlags { + flag = strings.TrimSpace(flag) + word := strings.TrimSpace(inv.ExplicitFlags[flag]) + if word == "" { + continue + } + resolved := word + if road.resolveModel != nil { + resolved, err = road.resolveModel(word) + if err != nil { + return err + } + } + inv.Line = carriedResolvedModel(inv.Line, flag, resolved) + modelsToWarm = append(modelsToWarm, strings.Split(resolved, ",")...) + } if asked == "" && road.defaultSeat != nil { road.seat, err = road.defaultSeat() if err != nil { diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go index 03c7fcf2f9..01ae4a6d37 100644 --- a/cmd/codeaf/carried_child_test.go +++ b/cmd/codeaf/carried_child_test.go @@ -141,6 +141,10 @@ func runAsCarriedChild() (int, bool) { restore := builtin.Override([]delegate.Delegate{fakeFolderProgram()}) defer restore() return execute(), true + case "model-flags": + restore := builtin.Override([]delegate.Delegate{fakeModelFlagsProgram()}) + defer restore() + return execute(), true case "real": return execute(), true } diff --git a/cmd/codeaf/carried_model_flags_test.go b/cmd/codeaf/carried_model_flags_test.go new file mode 100644 index 0000000000..3c07470ce9 --- /dev/null +++ b/cmd/codeaf/carried_model_flags_test.go @@ -0,0 +1,135 @@ +//go:build !windows + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/agentsession" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" +) + +// A real child asks both declared model flags, so resolving only the main +// flag cannot hide behind a passing parent parser or a tolerant provider. +func fakeModelFlagsProgram() delegate.Delegate { + return delegate.Delegate{Name: "fake-model-flags", Summary: "tests model resolution", Guide: "Tests the shell's model flags.", Page: "delegates", Default: "run", ModelFlag: "model", AuxiliaryModelFlags: []string{"light"}, Commands: []delegate.Command{{Name: "run", Usage: "[flags] -- ", Summary: "asks both models", Bind: func(fs *flag.FlagSet) delegate.Body { + model := fs.String("model", "", "the working model") + light := fs.String("light", "", "the light model") + return func(ctx context.Context, host delegate.Host, _ []string) error { + client, err := agentsession.NewClient(host.Models(), 0) + if err != nil { + return err + } + for _, selected := range []string{*model, *light} { + if selected == "" { + selected = *model + } + reply, err := client.Complete(ctx, agentsession.Request{Model: selected, Messages: []ai.Message{{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "answer"}}}}}) + if err != nil { + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: err.Error()}) + return nil + } + if reply.Text() != "done" { + return fmt.Errorf("unexpected answer") + } + } + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "both models answered"}) + return nil + } + }}}} +} + +func TestShellResolvesAuxiliaryModelsBeforeARealChildCallsTheProvider(t *testing.T) { + for _, tc := range []struct { + name, light string + refused bool + }{ + {"floating-alias", "fixture/floating-latest", false}, + {"native-id", "fixture/native", false}, + {"unknown-id", "fixture/missing", true}, + {"omitted-light", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + t.Setenv("CODEAF_HOME", home) + t.Setenv(config.ProfileDirEnv, home) + t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-fixture") + t.Setenv(carriedChildEnv, "model-flags") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + var mu sync.Mutex + var sent []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") { + rows := []any{} + for _, id := range []string{"~fixture/floating-latest", "fixture/native"} { + rows = append(rows, map[string]any{"id": id, "name": id, "context_length": 200000, "architecture": map[string]any{"input_modalities": []string{"text"}, "output_modalities": []string{"text"}}, "pricing": map[string]string{"prompt": "0", "completion": "0"}, "supported_parameters": []string{"tools", "tool_choice", "max_tokens"}}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": rows}) + return + } + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/chat/completions") { + http.NotFound(w, r) + return + } + var request struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), 400) + return + } + mu.Lock() + sent = append(sent, request.Model) + mu.Unlock() + if request.Model != "~fixture/floating-latest" && request.Model != "fixture/native" { + http.Error(w, `{"error":{"message":"invalid model ID"}}`, 404) + return + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"id\":\"fixture\",\"model\":%q,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2,\"cost\":0}}\n\ndata: [DONE]\n\n", request.Model) + })) + defer server.Close() + t.Setenv("CODEAF_BASE_URL", server.URL+"/api/v1") + oldCatalog, oldOut := sharedCatalog, carriedStdout + sharedCatalog, carriedStdout = newSharedCatalog(), &lockedBuffer{} + t.Cleanup(func() { sharedCatalog, carriedStdout = oldCatalog, oldOut }) + program := fakeModelFlagsProgram() + restore := builtin.Override([]delegate.Delegate{program}) + defer restore() + line := []string{"run", "--model", "fixture/floating-latest", "--dir", t.TempDir()} + if tc.light != "" { + line = append(line, "--light="+tc.light) + } + line = append(line, "--", "test both models") + err := runCarried(program, line) + mu.Lock() + got := append([]string(nil), sent...) + mu.Unlock() + if tc.refused { + if err == nil || !strings.Contains(err.Error(), "fixture/missing") || len(got) != 0 { + t.Fatalf("unknown light started calls or lacked its refusal: %v, %q", err, got) + } + return + } + wantLight := "~fixture/floating-latest" + if tc.light == "fixture/native" { + wantLight = tc.light + } + if exitCodeOf(err) != 0 || len(got) != 2 || got[0] != "~fixture/floating-latest" || got[1] != wantLight { + t.Fatalf("model flags = %q, error = %v", got, err) + } + }) + } +} diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go index 93f311569b..2732769f4b 100644 --- a/internal/delegate/delegate.go +++ b/internal/delegate/delegate.go @@ -177,6 +177,10 @@ type Delegate struct { // profile's work seat on the line when they typed nothing. Empty is a // program that takes no model. ModelFlag string + // AuxiliaryModelFlags names other model flags the shell resolves through + // the same profile. Only an explicitly written value is replaced; these + // flags do not choose the run's default work seat. + AuxiliaryModelFlags []string // CrewFlags is the flags the default command takes to use the models of // the conversation's crew ([Crew]), which codeaf puts on the line of every // run it starts from a conversation. Nil is a program that picks its own @@ -376,6 +380,12 @@ func (d Delegate) validateLineFlags() error { if flag := strings.TrimSpace(d.ModelFlag); flag != "" && !parses([]string{"--" + flag, "vendor/model"}) { return fmt.Errorf("%s: the model flag --%s is not a flag its %s command takes", d.Name, flag, command.Name) } + for _, declared := range d.AuxiliaryModelFlags { + flag := strings.TrimSpace(declared) + if flag == "" || !parses([]string{"--" + flag, "vendor/model"}) { + return fmt.Errorf("%s: the auxiliary model flag --%s is not a flag its %s command takes", d.Name, flag, command.Name) + } + } if len(d.PlainFolder) > 0 && !parses(d.PlainFolder) { return fmt.Errorf("%s: the plain folder flags %q are not flags its %s command takes", d.Name, strings.Join(d.PlainFolder, " "), command.Name) } diff --git a/internal/delegate/model_flags_test.go b/internal/delegate/model_flags_test.go new file mode 100644 index 0000000000..ed33b3c457 --- /dev/null +++ b/internal/delegate/model_flags_test.go @@ -0,0 +1,20 @@ +package delegate + +import ( + "strings" + "testing" +) + +func TestAuxiliaryModelFlagsMustBeDeclaredByTheCommand(t *testing.T) { + program := testProgram(nil) + program.AuxiliaryModelFlags = []string{"variant"} + if err := program.Validate(); err != nil { + t.Fatalf("a declared model flag was refused: %v", err) + } + for _, flag := range []string{"missing", ""} { + program.AuxiliaryModelFlags = []string{flag} + if err := program.Validate(); err == nil || !strings.Contains(err.Error(), "auxiliary model flag") { + t.Fatalf("undeclared auxiliary flag %q was not refused: %v", flag, err) + } + } +} diff --git a/internal/praf/praf.go b/internal/praf/praf.go index 47e78c514d..82fd4e229d 100644 --- a/internal/praf/praf.go +++ b/internal/praf/praf.go @@ -77,13 +77,14 @@ var Program = delegate.Delegate{ "`post `), or handing the blocking findings to senior-dev as " + "one task that fixes them in this folder when it is the pull request's checkout, naming which. " + "Propose neither before they say yes to it.", - ModelFlag: "model", - CrewFlags: crewFlags, - StageWords: stageWords, - Present: presentActions, - Default: "run", - Page: Name, - Commands: []delegate.Command{runCommand}, + ModelFlag: "model", + AuxiliaryModelFlags: []string{"light"}, + CrewFlags: crewFlags, + StageWords: stageWords, + Present: presentActions, + Default: "run", + Page: Name, + Commands: []delegate.Command{runCommand}, } // title is a typed run's title. diff --git a/internal/secaf/secaf.go b/internal/secaf/secaf.go index 6572a9e36b..345692e9e0 100644 --- a/internal/secaf/secaf.go +++ b/internal/secaf/secaf.go @@ -99,13 +99,14 @@ var Program = delegate.Delegate{ // the person's money, so the conversation offers it and waits. FollowUp: "If it found confirmed or likely problems, offer to hand them to senior-dev as one task that fixes them, " + "naming which; propose that task only after the person says yes.", - ModelFlag: "model", - CrewFlags: crewFlags, - StageWords: stageWords, - Present: presentActions, - Default: "run", - Page: Name, - Commands: []delegate.Command{runCommand}, + ModelFlag: "model", + AuxiliaryModelFlags: []string{"light"}, + CrewFlags: crewFlags, + StageWords: stageWords, + Present: presentActions, + Default: "run", + Page: Name, + Commands: []delegate.Command{runCommand}, } // crewFlags is the conversation's crew as the audit's own flags: the working From b6d939da73d202ed91beb5852e7394dc71730a21 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:02:22 -0400 Subject: [PATCH 2/5] fix(agentsession): canonicalize owned repository roots --- internal/agentsession/app.go | 3 +- internal/agentsession/root_alias_test.go | 93 ++++++++++++++++++++++++ internal/agentsession/session.go | 5 +- internal/agentsession/tools.go | 25 ++++++- internal/manual/chat/review.md | 4 + internal/manual/chat/sec.md | 2 + 6 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 internal/agentsession/root_alias_test.go diff --git a/internal/agentsession/app.go b/internal/agentsession/app.go index 91633917fd..fc9af45b7c 100644 --- a/internal/agentsession/app.go +++ b/internal/agentsession/app.go @@ -161,7 +161,8 @@ func (a *App) Harness(ctx context.Context, prompt string, schema map[string]any, // THE REPOSITORY IS THE APP'S, NOT THE CALLER'S. Every agent names the // folder it was handed; one that named another would be read nowhere. if project := strings.TrimSpace(opts.ProjectDir); project != "" && filepath.Clean(project) != filepath.Clean(root) { - if real, err := filepath.EvalSymlinks(project); err != nil || !within(root, real) { + realRoot, rootErr := filepath.EvalSymlinks(root) + if real, err := filepath.EvalSymlinks(project); err != nil || rootErr != nil || !within(realRoot, real) { return nil, fmt.Errorf("%s asked to read %s, which is not the repository this run reads", label, project) } } diff --git a/internal/agentsession/root_alias_test.go b/internal/agentsession/root_alias_test.go new file mode 100644 index 0000000000..908764c1d8 --- /dev/null +++ b/internal/agentsession/root_alias_test.go @@ -0,0 +1,93 @@ +package agentsession + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/agentsession/appx" +) + +func TestSessionCanReadARepositoryWhoseRootIsASymlink(t *testing.T) { + root := repo(t) + alias := filepath.Join(t.TempDir(), "repository-alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("this platform cannot create the root alias: %v", err) + } + api := (&fakeAPI{}).then(callTool("read_file", `{"path":"README.md"}`)).then(func(body wireRequest) (int, any) { + observation := body.Messages[len(body.Messages)-1].Content[0].Text + if !strings.Contains(observation, "# demo") || strings.Contains(observation, "outside") { + t.Errorf("a file inside the aliased repository was refused: %s", observation) + } + return answer("done", 0)(body) + }) + result, err := RunSession(t.Context(), api.serve(t), SessionOrder{Root: alias, MaxTurns: 6, Policy: testPolicy}) + if err != nil || result.Failed != "" { + t.Fatalf("session = %+v, error = %v", result, err) + } +} + +func TestAliasedRootKeepsEveryToolInsideTheRepository(t *testing.T) { + root := repo(t) + alias := filepath.Join(t.TempDir(), "repository-alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("this platform cannot create the root alias: %v", err) + } + tools, err := newToolbox(alias, testPolicy.Tools) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{"README.md", filepath.Join(alias, "README.md"), filepath.Join(root, "README.md")} { + if text, err := tools.read(path, 0, 0); err != nil || !strings.Contains(text, "# demo") { + t.Fatalf("read %q = %q, %v", path, text, err) + } + } + if text, err := tools.list("."); err != nil || !strings.Contains(text, "README.md") { + t.Fatalf("list = %q, %v", text, err) + } + if text, err := tools.glob("**/*.py"); err != nil || !strings.Contains(text, "app/views.py") { + t.Fatalf("glob = %q, %v", text, err) + } + if text, err := tools.grep("popen", ".", "", false); err != nil || !strings.Contains(text, "app/views.py:4:") { + t.Fatalf("grep = %q, %v", text, err) + } + outside := t.TempDir() + secret := filepath.Join(outside, "outside.txt") + if err := os.WriteFile(secret, []byte("outside the owned repository"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(secret, filepath.Join(root, "escape.txt")); err != nil { + t.Fatal(err) + } + // A path outside the lexical root is still refused even if it links in. + inward := filepath.Join(outside, "inward.txt") + if err := os.Symlink(filepath.Join(root, "README.md"), inward); err != nil { + t.Fatal(err) + } + for _, path := range []string{secret, inward, "escape.txt", filepath.Join(alias, "escape.txt"), "../outside.txt"} { + if _, err := tools.read(path, 0, 0); err == nil { + t.Fatalf("read escaped through %q", path) + } + } +} + +func TestHarnessAcceptsTheCanonicalSpellingOfItsAliasedRepository(t *testing.T) { + root := repo(t) + alias := filepath.Join(t.TempDir(), "repository-alias") + if err := os.Symlink(root, alias); err != nil { + t.Skipf("this platform cannot create the root alias: %v", err) + } + api := (&fakeAPI{}).then(answer("done", 0)) + app, err := New(api.serve(t), Config{Root: alias, Sessions: 1, Calls: 1, MaxTurns: 6, SessionWall: time.Minute, Policy: testPolicy}) + if err != nil { + t.Fatal(err) + } + if result, err := app.Harness(t.Context(), "answer", nil, nil, appx.HarnessOptions{ProjectDir: root}); err != nil || result.IsError { + t.Fatalf("the canonical owned project was refused: %+v, %v", result, err) + } + if _, err := app.Harness(t.Context(), "answer", nil, nil, appx.HarnessOptions{ProjectDir: t.TempDir()}); err == nil { + t.Fatal("an unrelated project was accepted") + } +} diff --git a/internal/agentsession/session.go b/internal/agentsession/session.go index a79dfb14ed..e08870173e 100644 --- a/internal/agentsession/session.go +++ b/internal/agentsession/session.go @@ -118,7 +118,10 @@ func runSession(ctx context.Context, complete func(context.Context, Request) (*a } validate = compiled } - tools := toolbox{root: order.Root, limits: policy.Tools} + tools, err := newToolbox(order.Root, policy.Tools) + if err != nil { + return result, err + } messages := []ai.Message{ textMessage("system", sessionSystem(order)), textMessage("user", order.Prompt), diff --git a/internal/agentsession/tools.go b/internal/agentsession/tools.go index 625bedeaca..174fc6edf4 100644 --- a/internal/agentsession/tools.go +++ b/internal/agentsession/tools.go @@ -41,10 +41,28 @@ var skippedDirs = map[string]bool{ // toolbox is the four tools over one repository. type toolbox struct { - root string + root string + // alias is the absolute spelling the caller supplied, when an ancestor + // or the root itself is a link. Both names identify the same owned root. + alias string limits ToolLimits } +func newToolbox(root string, limits ToolLimits) (toolbox, error) { + if strings.TrimSpace(root) == "" { + return toolbox{}, errors.New("the repository root is not set") + } + abs, err := filepath.Abs(root) + if err != nil { + return toolbox{}, err + } + real, err := filepath.EvalSymlinks(abs) + if err != nil { + return toolbox{}, fmt.Errorf("cannot open the repository: %s", plainFSError(err)) + } + return toolbox{root: real, alias: abs, limits: limits}, nil +} + // definitions is what the model is offered. func (t toolbox) definitions() []ai.ToolDefinition { tool := func(name, description string, properties map[string]any, required ...string) ai.ToolDefinition { @@ -123,6 +141,11 @@ func (t toolbox) resolve(name string) (string, error) { path := name if !filepath.IsAbs(path) { path = filepath.Join(t.root, path) + } else if t.alias != "" && within(t.alias, filepath.Clean(path)) { + // Translate only the caller's owned root spelling, not an arbitrary + // external link into the repository. Keep every escape check below. + rel, _ := filepath.Rel(t.alias, filepath.Clean(path)) + path = filepath.Join(t.root, rel) } path = filepath.Clean(path) if !within(t.root, path) { diff --git a/internal/manual/chat/review.md b/internal/manual/chat/review.md index bf276a0966..6325375238 100644 --- a/internal/manual/chat/review.md +++ b/internal/manual/chat/review.md @@ -47,6 +47,8 @@ Its stages are on the rail and head the lines of its page: Each reviewer is an agent session that reads the checkout, told it is part of a code review, with four read-only tools — read a file, list a folder, find files by name, look for text in files — within the turns and minutes its part of the review is given — from ten turns for the smallest to thirty for a reviewer. It reserves thirty seconds of its time limit for the answer, or half the remaining time when the limit is shorter than a minute. At the reading cutoff it stops reading and answers from what it has already found; the answer must still finish within the original time limit. At a shell, `--max-turns` and `--session-wall` set one limit for every reviewer instead. A review of a mid-sized pull request commonly takes half an hour to an hour. +A linked path to the repository is accepted as the same reading root. Files and links that lead outside that root are still refused. + ## What /review shows while it runs — each reviewer's line, what it found, which files Its page shows each stage once as the review enters it, then one line per finished step under it with what it found: `reviewed internal/auth/middleware.go, internal/auth/token.go and 2 more` · `3 findings`, and the step opened shows the first findings by title. A step that failed says `failed` and why. `ctrl+y` shows the raw model calls. @@ -74,6 +76,8 @@ It keeps three minutes of its time ceiling back to write its review. **A review **Which models.** From the chat, the conversation's working seat reads the code and its light seat makes the single structured calls; a model you name for the hand-off reads the code instead. At a shell, `--model` and `--light` name them, else the profile's work seat does both. +Both flags resolve model words through the profile's catalog, including floating aliases and provider-qualified names. An unknown model word is refused before any model call starts. + ## Fixing what /review found — hand the findings to senior-dev, fix the blocking findings When the review found something, the chat also offers to hand the blocking findings to senior-dev as one piece of work, when the conversation's folder is the pull request's checkout, and names which. It starts nothing until you say yes. You can also ask directly: "fix the two blocking findings with senior-dev". diff --git a/internal/manual/chat/sec.md b/internal/manual/chat/sec.md index 8d2471ab83..213ae6e3aa 100644 --- a/internal/manual/chat/sec.md +++ b/internal/manual/chat/sec.md @@ -98,6 +98,8 @@ What a run spends depends on the repository and the models: a `quick` audit of a **Which models.** From the chat, the conversation's working seat reads the code and its light seat makes the single quick calls (merging duplicates, the compliance mapping). A model you name for the hand-off reads the code in place of the working seat. At a shell, the profile's work seat does both unless `--model` (and `--light`) name others. +Both flags resolve model words through the profile's catalog, including floating aliases and provider-qualified names. An unknown model word is refused before any model call starts. A linked path to the repository is accepted as the same reading root; files and links that lead outside that root are still refused. + ## Fixing what sec found — hand the findings to senior-dev, fix the vulnerabilities When the audit finds confirmed or likely problems, the chat offers to hand them to senior-dev as one task that fixes them, and names which. It starts nothing until you say yes; then it proposes that task, and you answer its card as usual. You can also ask directly: "fix the two critical findings with senior-dev". From 4e49b78af1f5dbadb3f52c783a96cbab6a9978b8 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:07:34 -0400 Subject: [PATCH 3/5] test(update): compare the resolved installation path --- cmd/codeaf/chatv3_surface_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/chatv3_surface_test.go b/cmd/codeaf/chatv3_surface_test.go index d0507cf1aa..fd3b1a2c3a 100644 --- a/cmd/codeaf/chatv3_surface_test.go +++ b/cmd/codeaf/chatv3_surface_test.go @@ -76,6 +76,12 @@ func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *tes if err := os.WriteFile(target, []byte("old executable"), 0o755); err != nil { t.Fatal(err) } + // The installer reports the executable's physical path. A temporary + // directory on macOS can name that same file through a linked ancestor. + canonicalTarget, err := filepath.EvalSymlinks(target) + if err != nil { + t.Fatal(err) + } oldRun := runSurfaceProgram oldClient, oldExecutable := surfaceUpdateClient, surfaceExecutable oldRunningExecutable := surfaceRunningExecutable @@ -157,7 +163,7 @@ func TestRunSurfaceWiresTheDeferredLaunchCheckAndInstallerThroughRealInit(t *tes t.Fatal(err) } installed, err := os.ReadFile(target) - if err != nil || string(installed) != string(asset) || result.Path != target || resolved != 1 { + if err != nil || string(installed) != string(asset) || result.Path != canonicalTarget || resolved != 1 { t.Fatalf("result = %+v resolved = %d installed = %q, %v", result, resolved, installed, err) } } From 15640495a1c8305e74475a759d8f03208a1a570d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:35:32 -0400 Subject: [PATCH 4/5] fix(review): format final answers using published schema support --- internal/agentsession/client.go | 52 +++- internal/agentsession/policy.go | 4 + internal/agentsession/session.go | 38 ++- internal/agentsession/session_format_test.go | 292 +++++++++++++++++++ internal/config/parameter_test.go | 76 +++++ internal/config/window.go | 33 ++- internal/manual/chat/review.md | 4 + internal/praf/limits.go | 1 + 8 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 internal/agentsession/session_format_test.go create mode 100644 internal/config/parameter_test.go diff --git a/internal/agentsession/client.go b/internal/agentsession/client.go index 16a155f4ea..7f039da1dc 100644 --- a/internal/agentsession/client.go +++ b/internal/agentsession/client.go @@ -35,6 +35,7 @@ import ( "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/provider/modelapi" ) @@ -57,6 +58,9 @@ type Client struct { // call waits; a test shortens both. pause func(attempt int) time.Duration held func() time.Duration + // supportsParameter reads only the profile's remembered capabilities. + // Unknown facts never opt a session into a new provider request field. + supportsParameter func(model, parameter string) (supported, known bool) } // NewClient opens the run's model API. It refuses an API with no address or no @@ -79,8 +83,9 @@ func NewClient(api delegate.ModelAPI, retries int) (*Client, error) { // NO CLIENT TIMEOUT: a thinking model can be quiet for minutes, and the // call's own context (the session's wall, the run's stop) is what ends // it. - http: &http.Client{}, - pause: func(attempt int) time.Duration { return time.Duration(2<