Skip to content
Open
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
9 changes: 5 additions & 4 deletions internal/agents/vscode/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (a *Adapter) InstallCommand(_ system.PlatformProfile) ([][]string, error) {
// VS Code Copilot reads .instructions.md files from the VS Code User prompts folder.
// Skills are loaded from ~/.copilot/skills/ (global), .github/skills/ (workspace),
// ~/.claude/skills/, and .claude/skills/. We target ~/.copilot/skills/ for global reach.
// Native agent files are installed globally under ~/.copilot/agents/.

func (a *Adapter) GlobalConfigDir(homeDir string) string {
return filepath.Join(homeDir, ".copilot")
Expand Down Expand Up @@ -133,15 +134,15 @@ func (a *Adapter) CommandsDir(_ string) string {
}

func (a *Adapter) SupportsSubAgents() bool {
return false
return true
}

func (a *Adapter) SubAgentsDir(_ string) string {
return ""
func (a *Adapter) SubAgentsDir(homeDir string) string {
return filepath.Join(homeDir, ".copilot", "agents")
}

func (a *Adapter) EmbeddedSubAgentsDir() string {
return ""
return "vscode/agents"
}

func (a *Adapter) SupportsSkills() bool {
Expand Down
23 changes: 23 additions & 0 deletions internal/agents/vscode/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package vscode
import (
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/gentleman-programming/gentle-ai/internal/model"
Expand Down Expand Up @@ -91,3 +92,25 @@ func TestMCPConfigPathUsesVSCodeUserProfile(t *testing.T) {
}
}
}

func TestSubAgentSupportUsesCopilotUserAgentsDir(t *testing.T) {
a := NewAdapter()
home := "/tmp/home"

if !a.SupportsSubAgents() {
t.Fatal("VS Code adapter must advertise native sub-agent support")
}

got := a.SubAgentsDir(home)
want := filepath.Join(home, ".copilot", "agents")
if got != want {
t.Fatalf("SubAgentsDir() = %q, want %q", got, want)
}
if got == filepath.Join(home, ".github", "agents") || strings.Contains(got, string(filepath.Separator)+".github"+string(filepath.Separator)) {
t.Fatalf("SubAgentsDir() must not target workspace .github/agents, got %q", got)
}

if got := a.EmbeddedSubAgentsDir(); got != "vscode/agents" {
t.Fatalf("EmbeddedSubAgentsDir() = %q, want %q", got, "vscode/agents")
}
}
24 changes: 23 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ func tuiExecute(
CodexCarrilModelAssignments: selection.CodexCarrilModelAssignments,
CodexPhaseModelAssignments: selection.CodexPhaseModelAssignments,
ModelAssignments: modelAssignmentsToState(selection.ModelAssignments),
VSCodeModelAssignments: modelAssignmentsToState(selection.VSCodeModelAssignments),
Persona: string(selection.Persona),
})
}
Expand Down Expand Up @@ -549,6 +550,9 @@ func applyOverrides(selection *model.Selection, overrides *model.SyncOverrides)
if overrides.ModelAssignments != nil {
selection.ModelAssignments = overrides.ModelAssignments
}
if overrides.VSCodeModelAssignments != nil {
selection.VSCodeModelAssignments = overrides.VSCodeModelAssignments
}
if overrides.ClaudeModelAssignments != nil {
selection.ClaudeModelAssignments = overrides.ClaudeModelAssignments
}
Expand Down Expand Up @@ -657,6 +661,9 @@ func loadPersistedAssignments(homeDir string, selection *model.Selection) {
}
selection.ModelAssignments = m
}
if len(selection.VSCodeModelAssignments) == 0 && len(s.VSCodeModelAssignments) > 0 {
selection.VSCodeModelAssignments = stateModelAssignmentsToModel(s.VSCodeModelAssignments)
}
}

// persistAssignments writes the model assignments from selection back to
Expand All @@ -672,10 +679,11 @@ func persistAssignments(homeDir string, selection model.Selection) {
selection.ClaudePhaseAssignments != nil ||
selection.KiroModelAssignments != nil ||
selection.ModelAssignments != nil ||
selection.VSCodeModelAssignments != nil ||
selection.CodexModelAssignments != nil ||
selection.CodexCarrilModelAssignments != nil ||
selection.CodexPhaseModelAssignments != nil
if len(selection.ClaudeModelAssignments) == 0 && len(selection.ClaudePhaseAssignments) == 0 && len(selection.KiroModelAssignments) == 0 && len(selection.ModelAssignments) == 0 && len(selection.CodexModelAssignments) == 0 && len(selection.CodexCarrilModelAssignments) == 0 && len(selection.CodexPhaseModelAssignments) == 0 && !hasAssignmentSignal {
if len(selection.ClaudeModelAssignments) == 0 && len(selection.ClaudePhaseAssignments) == 0 && len(selection.KiroModelAssignments) == 0 && len(selection.ModelAssignments) == 0 && len(selection.VSCodeModelAssignments) == 0 && len(selection.CodexModelAssignments) == 0 && len(selection.CodexCarrilModelAssignments) == 0 && len(selection.CodexPhaseModelAssignments) == 0 && !hasAssignmentSignal {
return
}
current, err := state.Read(homeDir)
Expand Down Expand Up @@ -738,9 +746,23 @@ func persistAssignments(homeDir string, selection model.Selection) {
current.ModelAssignments = nil
}
}
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
}
Comment on lines +749 to +751

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Honor explicit clear semantics for VSCodeModelAssignments.

persistAssignments only writes VS Code assignments when the map is non-empty. If callers pass a non-nil empty map to clear assignments, stale persisted values remain and get rehydrated on later syncs.

Suggested fix
-	if len(selection.VSCodeModelAssignments) > 0 {
-		current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
-	}
+	if selection.VSCodeModelAssignments != nil {
+		if len(selection.VSCodeModelAssignments) > 0 {
+			current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
+		} else {
+			current.VSCodeModelAssignments = nil
+		}
+	}
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
}
if selection.VSCodeModelAssignments != nil {
if len(selection.VSCodeModelAssignments) > 0 {
current.VSCodeModelAssignments = modelAssignmentsToState(selection.VSCodeModelAssignments)
} else {
current.VSCodeModelAssignments = nil
}
}
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/app/app.go` around lines 749 - 751, The condition in the
VSCodeModelAssignments block at the if statement is checking if the length is
greater than zero, which prevents explicit clearing when a non-nil empty map is
passed. Change the condition from checking len(selection.VSCodeModelAssignments)
> 0 to instead checking if selection.VSCodeModelAssignments is not nil. This
will allow the modelAssignmentsToState function to process empty maps that
represent an explicit clear intent, while still allowing the caller to pass nil
if they want to skip processing assignments entirely.

_ = state.Write(homeDir, current)
}

func stateModelAssignmentsToModel(m map[string]state.ModelAssignmentState) map[string]model.ModelAssignment {
if len(m) == 0 {
return nil
}
out := make(map[string]model.ModelAssignment, len(m))
for k, v := range m {
out[k] = model.ModelAssignment{ProviderID: v.ProviderID, ModelID: v.ModelID, Effort: v.Effort}
}
return out
}

// claudeAliasesToStrings converts a typed ClaudeModelAlias map to plain strings
// for JSON serialisation in state.json.
func claudeAliasesToStrings(m map[string]model.ClaudeModelAlias) map[string]string {
Expand Down
66 changes: 66 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,34 @@ func TestTuiSyncSDDProfileStrategyEmptyOverrideNoChange(t *testing.T) {
}
}

func TestApplyOverridesVSCodeModelAssignments(t *testing.T) {
selection := model.Selection{
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"},
},
ModelAssignments: map[string]model.ModelAssignment{
"sdd-apply": {ProviderID: "anthropic", ModelID: "claude-opus-4"},
},
}
overrides := &model.SyncOverrides{
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-design": {ProviderID: "github-copilot", ModelID: "claude-sonnet-4"},
},
}

applyOverrides(&selection, overrides)

if _, exists := selection.VSCodeModelAssignments["sdd-apply"]; exists {
t.Fatal("VSCodeModelAssignments should be replaced as a whole map, not merged key-by-key")
}
if got := selection.VSCodeModelAssignments["sdd-design"].ProviderID; got != "github-copilot" {
t.Fatalf("VSCodeModelAssignments[sdd-design].ProviderID = %q, want github-copilot", got)
}
if got := selection.ModelAssignments["sdd-apply"].ProviderID; got != "anthropic" {
t.Fatalf("OpenCode ModelAssignments changed after VS Code override: provider = %q", got)
}
}

func boolPtr(b bool) *bool { return &b }

func TestTuiSyncTargetAgentsOverridePersistedInstallState(t *testing.T) {
Expand Down Expand Up @@ -666,6 +694,9 @@ func TestLoadPersistedAssignmentsPopulatesEmptySelection(t *testing.T) {
ModelAssignments: map[string]state.ModelAssignmentState{
"sdd-init": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"},
},
VSCodeModelAssignments: map[string]state.ModelAssignmentState{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "gpt-4.1"},
},
})
if err != nil {
t.Fatalf("state.Write: %v", err)
Expand All @@ -690,6 +721,10 @@ func TestLoadPersistedAssignmentsPopulatesEmptySelection(t *testing.T) {
if ma.ProviderID != "anthropic" || ma.ModelID != "claude-sonnet-4" {
t.Errorf("ModelAssignments[sdd-init] = %+v, want anthropic/claude-sonnet-4", ma)
}
vs := selection.VSCodeModelAssignments["sdd-apply"]
if vs.ProviderID != "github-copilot" || vs.ModelID != "gpt-4.1" {
t.Errorf("VSCodeModelAssignments[sdd-apply] = %+v, want github-copilot/gpt-4.1", vs)
}
}

// TestLoadPersistedAssignmentsDoesNotOverrideExisting verifies that when the
Expand All @@ -704,6 +739,9 @@ func TestLoadPersistedAssignmentsDoesNotOverrideExisting(t *testing.T) {
ModelAssignments: map[string]state.ModelAssignmentState{
"sdd-init": {ProviderID: "google", ModelID: "gemini-pro"},
},
VSCodeModelAssignments: map[string]state.ModelAssignmentState{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "old-model"},
},
})
if err != nil {
t.Fatalf("state.Write: %v", err)
Expand All @@ -717,6 +755,9 @@ func TestLoadPersistedAssignmentsDoesNotOverrideExisting(t *testing.T) {
ModelAssignments: map[string]model.ModelAssignment{
"sdd-init": {ProviderID: "anthropic", ModelID: "claude-sonnet-4"},
},
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-apply": {ProviderID: "github-copilot", ModelID: "new-model"},
},
}
loadPersistedAssignments(home, &selection)

Expand All @@ -728,6 +769,9 @@ func TestLoadPersistedAssignmentsDoesNotOverrideExisting(t *testing.T) {
if ma.ProviderID != "anthropic" {
t.Errorf("ModelAssignments[sdd-init].ProviderID = %q, want %q (should not be overwritten)", ma.ProviderID, "anthropic")
}
if got := selection.VSCodeModelAssignments["sdd-apply"].ModelID; got != "new-model" {
t.Errorf("VSCodeModelAssignments[sdd-apply].ModelID = %q, want new-model (should not be overwritten)", got)
}
}

// TestPersistAssignmentsPreservesInstalledAgents verifies the read-merge-write
Expand Down Expand Up @@ -927,6 +971,28 @@ func TestPersistAndLoadKiroModelAssignments(t *testing.T) {
}
}

func TestPersistAndLoadVSCodeModelAssignments(t *testing.T) {
home := t.TempDir()

selection := model.Selection{
VSCodeModelAssignments: map[string]model.ModelAssignment{
"sdd-orchestrator": {ProviderID: "github-copilot", ModelID: "gpt-4.1"},
"sdd-apply": {ProviderID: "github-copilot", ModelID: "claude-sonnet-4", Effort: "low"},
},
}
persistAssignments(home, selection)

loaded := model.Selection{}
loadPersistedAssignments(home, &loaded)

if got := loaded.VSCodeModelAssignments["sdd-orchestrator"].ModelID; got != "gpt-4.1" {
t.Fatalf("VSCodeModelAssignments[sdd-orchestrator].ModelID = %q, want gpt-4.1", got)
}
if got := loaded.VSCodeModelAssignments["sdd-apply"].Effort; got != "low" {
t.Fatalf("VSCodeModelAssignments[sdd-apply].Effort = %q, want low", got)
}
}

// TestPersistAssignmentsNoOpWhenEmpty verifies that persistAssignments does
// not write to state.json when the selection has no assignments.
func TestPersistAssignmentsNoOpWhenEmpty(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/assets/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package assets

import "embed"

//go:embed all:claude all:opencode all:generic all:skills all:gga all:gemini all:codex all:antigravity all:windsurf all:cursor all:kimi all:qwen all:kiro all:hermes
//go:embed all:claude all:opencode all:generic all:skills all:gga all:gemini all:codex all:antigravity all:windsurf all:cursor all:vscode all:kimi all:qwen all:kiro all:hermes
var FS embed.FS

// MustRead returns the content of an embedded file or panics.
Expand Down
137 changes: 137 additions & 0 deletions internal/assets/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,143 @@ func TestFourRReviewAgentAssets(t *testing.T) {
}
}

var vscodeSDDPhaseAgents = []string{
"sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design",
"sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard",
}

func TestVSCodeNativeAgentAssetsFrontmatter(t *testing.T) {
coordinatorPath := "vscode/agents/sdd-orchestrator.agent.md"
coordinator := readFrontmatterBlock(t, coordinatorPath)
requireFrontmatterLine(t, coordinator, "target: vscode")
requireFrontmatterLine(t, coordinator, "user-invocable: true")
requireFrontmatterLine(t, coordinator, "disable-model-invocation: true")
requireInlineTool(t, coordinator, "agent")
requireNoDeprecatedVSCodeTools(t, coordinator)
requireAgentsAllowlist(t, coordinator, vscodeSDDPhaseAgents)
requireFrontmatterKeyAbsent(t, coordinator, "model")
requireFrontmatterKeyAbsent(t, coordinator, "infer")
requireAssetBodyContains(t, coordinatorPath, "## Agent Teams Orchestrator", "## SDD Workflow", "### Review Workload Guard")
requireAssetBodyNotContains(t, coordinatorPath, "## Model Assignments", "model parameter")

for _, phase := range vscodeSDDPhaseAgents {
t.Run(phase, func(t *testing.T) {
path := "vscode/agents/" + phase + ".agent.md"
frontmatter := readFrontmatterBlock(t, path)
requireFrontmatterLine(t, frontmatter, "target: vscode")
requireFrontmatterLine(t, frontmatter, "user-invocable: false")
requireFrontmatterKeyAbsent(t, frontmatter, "model")
requireFrontmatterKeyAbsent(t, frontmatter, "infer")
requireNoDeprecatedVSCodeTools(t, frontmatter)
if strings.Contains(frontmatterKeyLine(frontmatter, "tools"), "agent") {
t.Fatalf("%s must not include coordinator-only agent tool", phase)
}
for _, tool := range expectedVSCodePhaseTools(phase) {
requireInlineTool(t, frontmatter, tool)
}
requireAssetBodyContains(t, path, "## Instructions", "## Engram Save", "## Result Contract")
})
}
}

func expectedVSCodePhaseTools(phase string) []string {
switch phase {
case "sdd-explore":
return []string{"read", "search", "web"}
case "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-archive":
return []string{"read", "search", "edit"}
case "sdd-verify":
return []string{"read", "search", "execute"}
default:
return []string{"read", "search", "edit", "execute"}
}
}

func requireNoDeprecatedVSCodeTools(t *testing.T, frontmatter string) {
t.Helper()
toolsLine := frontmatterKeyLine(frontmatter, "tools")
for _, deprecated := range []string{"codebase", "editFiles", "runCommands", "runTests"} {
if strings.Contains(toolsLine, deprecated) {
t.Fatalf("tools line %q uses deprecated VS Code tool alias %q", toolsLine, deprecated)
}
}
}

func requireAssetBodyContains(t *testing.T, path string, required ...string) {
t.Helper()
content := strings.ReplaceAll(MustRead(path), "\r\n", "\n")
for _, want := range required {
if !strings.Contains(content, want) {
t.Fatalf("%s missing body content %q", path, want)
}
}
}

func requireAssetBodyNotContains(t *testing.T, path string, forbidden ...string) {
t.Helper()
content := strings.ReplaceAll(MustRead(path), "\r\n", "\n")
for _, nope := range forbidden {
if strings.Contains(content, nope) {
t.Fatalf("%s contains out-of-scope body content %q", path, nope)
}
}
}

func readFrontmatterBlock(t *testing.T, path string) string {
t.Helper()
content := strings.ReplaceAll(MustRead(path), "\r\n", "\n")
if !strings.HasPrefix(content, "---\n") {
t.Fatalf("%s missing YAML frontmatter", path)
}
rest := content[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
t.Fatalf("%s missing YAML frontmatter close", path)
}
return "\n" + rest[:end] + "\n"
}

func requireFrontmatterLine(t *testing.T, frontmatter, line string) {
t.Helper()
if !strings.Contains(frontmatter, "\n"+line+"\n") {
t.Fatalf("frontmatter missing line %q:\n%s", line, frontmatter)
}
}

func requireFrontmatterKeyAbsent(t *testing.T, frontmatter, key string) {
t.Helper()
if frontmatterKeyLine(frontmatter, key) != "" {
t.Fatalf("frontmatter must not include %q", key)
}
}

func requireInlineTool(t *testing.T, frontmatter, tool string) {
t.Helper()
line := frontmatterKeyLine(frontmatter, "tools")
if !strings.Contains(line, tool) {
t.Fatalf("tools line %q missing %q", line, tool)
}
}

func requireAgentsAllowlist(t *testing.T, frontmatter string, want []string) {
t.Helper()
if strings.Count(frontmatter, "\n - ") != len(want) {
t.Fatalf("coordinator agents allowlist must contain only %v:\n%s", want, frontmatter)
}
for _, agent := range want {
requireFrontmatterLine(t, frontmatter, " - "+agent)
}
}

func frontmatterKeyLine(frontmatter, key string) string {
for _, line := range strings.Split(frontmatter, "\n") {
if strings.HasPrefix(line, key+":") {
return line
}
}
return ""
}

func TestOpenCodeSDDOrchestratorRequiresSessionPreflight(t *testing.T) {
content := MustRead("opencode/sdd-orchestrator.md")

Expand Down
Loading