Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions backend/internal/adapters/agent/copilot/copilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ func (p *Plugin) Manifest() adapters.Manifest {
}
}

// GetConfigSpec reports the per-project agent config keys the GitHub Copilot
// CLI understands: a model override passed via --model.
func (p *Plugin) GetConfigSpec(ctx context.Context) (ports.ConfigSpec, error) {
if err := ctx.Err(); err != nil {
return ports.ConfigSpec{}, err
}
return ports.ConfigSpec{
Fields: []ports.ConfigField{
{
Key: "model",
Type: ports.ConfigFieldString,
Description: "Model override passed to `copilot --model` (e.g. claude-sonnet-4.5).",
},
},
}, nil
}

// GetLaunchCommand builds the argv to start a new interactive Copilot session:
//
// copilot [permission flags] [--agent ao-<session>] [--interactive <prompt>]
Expand All @@ -89,6 +106,7 @@ func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) (

cmd = append(cmd, binary)
appendApprovalFlags(&cmd, cfg.Permissions)
appendModelFlag(&cmd, cfg.Config)
if agentName := copilotAgentName(cfg.SessionID, cfg.SystemPrompt, cfg.SystemPromptFile); agentName != "" {
cmd = append(cmd, "--agent="+agentName)
}
Expand Down Expand Up @@ -133,6 +151,10 @@ func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig)

cmd = append(cmd, binary)
appendApprovalFlags(&cmd, cfg.Permissions)
// Deliberately does not forward cfg.Config.Model: --model + --resume
// composition is unverified against a real copilot install (see #2895
// and appendModelFlag). Pinned by
// TestGetRestoreCommandDoesNotForwardModelOverride.
if agentName := copilotAgentName(cfg.Session.ID, cfg.SystemPrompt, cfg.SystemPromptFile); agentName != "" {
cmd = append(cmd, "--agent="+agentName)
}
Expand Down Expand Up @@ -318,3 +340,24 @@ func appendApprovalFlags(cmd *[]string, permissions ports.PermissionMode) {
*cmd = append(*cmd, "--allow-all")
}
}

// appendModelFlag appends `--model <trimmed>` when cfg.Model is set,
// mirroring the Codex adapter's pattern (see #2869) and taking the full
// ports.AgentConfig for consistency with the sibling adapters. A blank or
// whitespace-only value is omitted so Copilot falls back to its own default
// resolution (COPILOT_MODEL env, or its configured default) exactly as an
// unconfigured launch would.
//
// Restore-path note: this is intentionally NOT called from
// GetRestoreCommand. An attempt to verify `--model` + `--resume`
// composition against a real copilot install was inconclusive: the test
// account is on the Copilot Free plan, which only grants access to "auto"
// regardless of --model, on both launch and resume. That makes the plan
// itself a confound, not evidence either way about whether --resume
// honors --model. Left as a fast-follow pending verification from an
// account with paid-plan model access (see #2895).
func appendModelFlag(cmd *[]string, cfg ports.AgentConfig) {
if trimmed := strings.TrimSpace(cfg.Model); trimmed != "" {
*cmd = append(*cmd, "--model", trimmed)
}
}
86 changes: 83 additions & 3 deletions backend/internal/adapters/agent/copilot/copilot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,95 @@ func TestGetLaunchCommandSelectsSessionCustomAgent(t *testing.T) {
}
}

func TestGetConfigSpecHasNoCustomFieldsYet(t *testing.T) {
func TestGetConfigSpecReportsModelField(t *testing.T) {
plugin := &Plugin{}

spec, err := plugin.GetConfigSpec(context.Background())
if err != nil {
t.Fatalf("GetConfigSpec: %v", err)
}

var found bool
for _, f := range spec.Fields {
if f.Key != "model" {
continue
}
found = true
if f.Type != ports.ConfigFieldString {
t.Errorf("model field Type = %v, want %v", f.Type, ports.ConfigFieldString)
}
if f.Description == "" {
t.Error("model field Description is empty")
}
}
if !found {
t.Fatalf("GetConfigSpec did not report a \"model\" field: %#v", spec.Fields)
}
}

func TestGetConfigSpecRespectsCanceledContext(t *testing.T) {
plugin := &Plugin{}
ctx, cancel := context.WithCancel(context.Background())
cancel()

if _, err := plugin.GetConfigSpec(ctx); err == nil {
t.Fatal("GetConfigSpec with canceled context: err = nil, want non-nil")
}
}

func TestGetLaunchCommandAppendsModelFlag(t *testing.T) {
plugin := &Plugin{resolvedBinary: "copilot"}

cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{Model: "claude-sonnet-4.5"},
})
if err != nil {
t.Fatal(err)
}
if len(spec.Fields) != 0 {
t.Fatalf("unexpected config fields: %#v", spec.Fields)
want := []string{"copilot", "--model", "claude-sonnet-4.5"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v", cmd, want)
}
}

func TestGetLaunchCommandOmitsBlankModel(t *testing.T) {
plugin := &Plugin{resolvedBinary: "copilot"}

cmd, err := plugin.GetLaunchCommand(context.Background(), ports.LaunchConfig{
Config: ports.AgentConfig{Model: " "},
})
if err != nil {
t.Fatal(err)
}
if contains(cmd, "--model") {
t.Fatalf("command %#v unexpectedly contains --model for blank config", cmd)
}
}

// Restore path intentionally does not forward a configured model override —
// see appendModelFlag's doc comment (issue #2895; --model + --resume
// composition could not be verified — the test account's Copilot Free plan
// only grants "auto" regardless of --model). This test locks that decision
// in so a future "just mirror the launch path" edit doesn't silently wire
// it without re-verifying.
func TestGetRestoreCommandDoesNotForwardModelOverride(t *testing.T) {
plugin := &Plugin{resolvedBinary: "copilot"}

cmd, ok, err := plugin.GetRestoreCommand(context.Background(), ports.RestoreConfig{
Config: ports.AgentConfig{Model: "claude-sonnet-4.5"},
Session: ports.SessionRef{
Metadata: map[string]string{ports.MetadataKeyAgentSessionID: "uuid-123"},
},
})
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if !ok {
t.Fatal("ok = false, want true")
}
want := []string{"copilot", "--resume", "uuid-123"}
if !reflect.DeepEqual(cmd, want) {
t.Fatalf("cmd = %#v, want %#v (model override must not be forwarded on restore)", cmd, want)
}
}

Expand Down
Loading