detached 406ddf4

Merge pull request #66 from bnema/fix/web-research-audit

fix(subagents): normalize qualified model IDs
commit
406ddf42aa1ce9d80a0687a1eeda9cac0e0f4845
author
bnema · · 2026-08-07 08:52 UTC
committer
GitHub · · 2026-08-07 08:52 UTC
merge of
84a0b9d + 1ff3f88 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.9.1
3 files · +58 -0
M packages/agent/subagents/supervisor.go +13 -0
@@ -374,6 +374,18 @@ func (f *Supervisor) Spawn(ctx context.Context, task string) (*Agent, error) {
374374 return f.SpawnReq(ctx, SpawnRequest{Task: task})
375375 }
376376
377+// unqualifiedModelID removes a redundant provider prefix from a model ID.
378+// Model IDs themselves may contain slashes, so only the explicitly selected
379+// provider's exact prefix is removed.
380+func unqualifiedModelID(provider, model string) string {
381+ provider = strings.TrimSpace(provider)
382+ model = strings.TrimSpace(model)
383+ if provider == "" {
384+ return model
385+ }
386+ return strings.TrimPrefix(model, provider+"/")
387+}
388+
377389 // SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest.
378390 // Existing callers can keep using Spawn; new code that wants to pin the
379391 // child's model, policy, or workspace uses this.
@@ -388,6 +400,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
388400 if err := f.validateSpawnRequest(req); err != nil {
389401 return nil, err
390402 }
403+ req.Model = unqualifiedModelID(req.Provider, req.Model)
391404 if ctx == nil {
392405 ctx = context.Background()
393406 }
@@ -374,6 +374,18 @@ func (f *Supervisor) Spawn(ctx context.Context, task string) (*Agent, error) {
374 return f.SpawnReq(ctx, SpawnRequest{Task: task})374 return f.SpawnReq(ctx, SpawnRequest{Task: task})
375 }375 }
376 376
377+// unqualifiedModelID removes a redundant provider prefix from a model ID.
378+// Model IDs themselves may contain slashes, so only the explicitly selected
379+// provider's exact prefix is removed.
380+func unqualifiedModelID(provider, model string) string {
381+ provider = strings.TrimSpace(provider)
382+ model = strings.TrimSpace(model)
383+ if provider == "" {
384+ return model
385+ }
386+ return strings.TrimPrefix(model, provider+"/")
387+}
388+
377 // SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest.389 // SpawnReq is the full-fat variant of Spawn that accepts a SpawnRequest.
378 // Existing callers can keep using Spawn; new code that wants to pin the390 // Existing callers can keep using Spawn; new code that wants to pin the
379 // child's model, policy, or workspace uses this.391 // child's model, policy, or workspace uses this.
@@ -388,6 +400,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
388 if err := f.validateSpawnRequest(req); err != nil {400 if err := f.validateSpawnRequest(req); err != nil {
389 return nil, err401 return nil, err
390 }402 }
403+ req.Model = unqualifiedModelID(req.Provider, req.Model)
391 if ctx == nil {404 if ctx == nil {
392 ctx = context.Background()405 ctx = context.Background()
393 }406 }
M packages/agent/subagents/supervisor_test.go +20 -0
@@ -23,6 +23,26 @@ func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
2323 })
2424 }
2525
26+func TestUnqualifiedModelID(t *testing.T) {
27+ for _, tc := range []struct {
28+ name string
29+ provider string
30+ model string
31+ want string
32+ }{
33+ {name: "matching provider", provider: "openai-codex", model: "openai-codex/gpt-5.6-sol", want: "gpt-5.6-sol"},
34+ {name: "matching provider with whitespace", provider: "openai-codex", model: " openai-codex/gpt-5.6-sol ", want: "gpt-5.6-sol"},
35+ {name: "other slash-containing model ID", provider: "openrouter", model: "anthropic/claude-sonnet-4-5", want: "anthropic/claude-sonnet-4-5"},
36+ {name: "no provider", model: "/custom-model", want: "/custom-model"},
37+ } {
38+ t.Run(tc.name, func(t *testing.T) {
39+ if got := unqualifiedModelID(tc.provider, tc.model); got != tc.want {
40+ t.Fatalf("unqualifiedModelID(%q, %q) = %q, want %q", tc.provider, tc.model, got, tc.want)
41+ }
42+ })
43+ }
44+}
45+
2646 func TestSpawnRunsAndCompletes(t *testing.T) {
2747 ran := make(chan string, 1)
2848 f := newTestSupervisor(t, func(a *Agent) Runner {
@@ -23,6 +23,26 @@ func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
23 })23 })
24 }24 }
25 25
26+func TestUnqualifiedModelID(t *testing.T) {
27+ for _, tc := range []struct {
28+ name string
29+ provider string
30+ model string
31+ want string
32+ }{
33+ {name: "matching provider", provider: "openai-codex", model: "openai-codex/gpt-5.6-sol", want: "gpt-5.6-sol"},
34+ {name: "matching provider with whitespace", provider: "openai-codex", model: " openai-codex/gpt-5.6-sol ", want: "gpt-5.6-sol"},
35+ {name: "other slash-containing model ID", provider: "openrouter", model: "anthropic/claude-sonnet-4-5", want: "anthropic/claude-sonnet-4-5"},
36+ {name: "no provider", model: "/custom-model", want: "/custom-model"},
37+ } {
38+ t.Run(tc.name, func(t *testing.T) {
39+ if got := unqualifiedModelID(tc.provider, tc.model); got != tc.want {
40+ t.Fatalf("unqualifiedModelID(%q, %q) = %q, want %q", tc.provider, tc.model, got, tc.want)
41+ }
42+ })
43+ }
44+}
45+
26 func TestSpawnRunsAndCompletes(t *testing.T) {46 func TestSpawnRunsAndCompletes(t *testing.T) {
27 ran := make(chan string, 1)47 ran := make(chan string, 1)
28 f := newTestSupervisor(t, func(a *Agent) Runner {48 f := newTestSupervisor(t, func(a *Agent) Runner {
M packages/agent/tools/subagent_spawn_test.go +25 -0
@@ -389,6 +389,31 @@ func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
389389 }
390390 }
391391
392+func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) {
393+ tool := &SubagentSpawnTool{
394+ Supervisor: newTestSupervisor(t),
395+ Enabled: func() bool { return true },
396+ }
397+
398+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), nil)
399+ if err != nil {
400+ t.Fatal(err)
401+ }
402+ if res.IsError {
403+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
404+ }
405+ agents := tool.Supervisor.List()
406+ if len(agents) != 1 {
407+ t.Fatalf("spawned agents = %d, want 1", len(agents))
408+ }
409+ if got := agents[0].Model; got != "gpt-5.6-sol" {
410+ t.Fatalf("agent model = %q, want unqualified model ID", got)
411+ }
412+ if got := agents[0].Provider; got != "openai-codex" {
413+ t.Fatalf("agent provider = %q, want openai-codex", got)
414+ }
415+}
416+
392417 func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
393418 tool := &SubagentSpawnTool{
394419 Supervisor: newTestSupervisor(t),
@@ -389,6 +389,31 @@ func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
389 }389 }
390 }390 }
391 391
392+func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) {
393+ tool := &SubagentSpawnTool{
394+ Supervisor: newTestSupervisor(t),
395+ Enabled: func() bool { return true },
396+ }
397+
398+ res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), nil)
399+ if err != nil {
400+ t.Fatal(err)
401+ }
402+ if res.IsError {
403+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
404+ }
405+ agents := tool.Supervisor.List()
406+ if len(agents) != 1 {
407+ t.Fatalf("spawned agents = %d, want 1", len(agents))
408+ }
409+ if got := agents[0].Model; got != "gpt-5.6-sol" {
410+ t.Fatalf("agent model = %q, want unqualified model ID", got)
411+ }
412+ if got := agents[0].Provider; got != "openai-codex" {
413+ t.Fatalf("agent provider = %q, want openai-codex", got)
414+ }
415+}
416+
392 func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {417 func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
393 tool := &SubagentSpawnTool{418 tool := &SubagentSpawnTool{
394 Supervisor: newTestSupervisor(t),419 Supervisor: newTestSupervisor(t),

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close