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
16 changes: 5 additions & 11 deletions internal/cli/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1988,30 +1988,24 @@ func TestRunSyncWithProfilesIntegration(t *testing.T) {
t.Errorf("opencode.json should contain balanced orchestrator model 'claude-sonnet-4-5'")
}

// Verify prompt files exist in ~/.config/opencode/prompts/sdd/.
// Verify prompt references are relative to the generated OpenCode settings.
// Note: prompt files are written only for multi-mode. For single-mode syncs,
// profile sub-agents use {file:...} references that rely on prompts being written
// during a prior multi-mode sync. Check that the profile overlay is written correctly
// by verifying the agent keys themselves are present (already done above).
// The prompt directory is populated by the profile generator which calls
// SharedPromptDir internally — verify the directory path is referenced correctly.
promptDir := filepath.Join(home, ".config", "opencode", "prompts", "sdd")
promptPhases := []string{
"sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design",
"sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard",
}
// Verify the opencode.json file references mention the correct prompt directory.
slashPromptDir := filepath.ToSlash(promptDir)
if !strings.Contains(settingsStr, slashPromptDir) {
t.Errorf("opencode.json should reference prompt directory %q", slashPromptDir)
}
// Verify all phase prompt file references appear in the settings.
for _, phase := range promptPhases {
promptRef := filepath.ToSlash(filepath.Join(promptDir, phase+".md"))
promptRef := "{file:./prompts/sdd/" + phase + ".md}"
if !strings.Contains(settingsStr, promptRef) {
t.Errorf("opencode.json should contain prompt file reference for %q", promptRef)
}
}
if slashHome := filepath.ToSlash(home); strings.Contains(settingsStr, slashHome) {
t.Errorf("opencode.json should not contain temp home path %q", slashHome)
}

// Run 2: same selection → all assets already current → filesChanged=0.
// Note: The second sync with profiles will re-generate the overlay, but since
Expand Down
9 changes: 1 addition & 8 deletions internal/components/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,7 @@ func TestGoldenSDD_OpenCode_Multi(t *testing.T) {
t.Fatalf("multi-mode settings missing orchestrator tool %s", toolName)
}
}
// Normalize the absolute home path in the settings JSON so the golden
// file remains stable across test runs (temp dirs change each run).
// Sub-agent prompts now use {file:/abs/path/...} references.
jsonStr := string(settingsJSON)
jsonStr = strings.ReplaceAll(jsonStr, home, "{{HOME}}")
jsonStr = strings.ReplaceAll(jsonStr, filepath.ToSlash(home), "{{HOME}}")
normalizedSettings := []byte(jsonStr)
assertGolden(t, "sdd-opencode-multi-settings.golden", normalizedSettings)
assertGolden(t, "sdd-opencode-multi-settings.golden", settingsJSON)

legacyPluginPath := filepath.Join(home, ".config", "opencode", "plugins", "background-agents.ts")
if _, err := os.Stat(legacyPluginPath); !os.IsNotExist(err) {
Expand Down
11 changes: 7 additions & 4 deletions internal/components/sdd/inject.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ func Inject(homeDir string, adapter agents.Adapter, sddMode model.SDDModeID, opt
return InjectionResult{}, fmt.Errorf("clean stale profile JD agents %q: %w", profile.Name, cleanupErr)
}
changed = changed || cleanupResult.Changed
profileOverlay, profileErr := GenerateProfileOverlay(profile, homeDir, opts.CodeGraphGuidanceMarkdown)
profileOverlay, profileErr := GenerateProfileOverlay(profile, homeDir, settingsPath, opts.CodeGraphGuidanceMarkdown)
if profileErr != nil {
return InjectionResult{}, fmt.Errorf("generate profile overlay %q: %w", profile.Name, profileErr)
}
Expand Down Expand Up @@ -913,10 +913,9 @@ func inlineOpenCodeSDDPrompts(overlayBytes []byte, homeDir, settingsPath string,
}
}

// Replace sub-agent prompt placeholders with {file:<absolutePath>} references.
// Replace sub-agent prompt placeholders with settings-relative file references.
// The placeholder format is __PROMPT_FILE_{phase}__ where {phase} is the agent name.
if homeDir != "" {
promptDir := SharedPromptDir(homeDir)
for _, phase := range subAgentPhaseOrder {
agentRaw, exists := agentsMap[phase]
if !exists {
Expand All @@ -928,7 +927,11 @@ func inlineOpenCodeSDDPrompts(overlayBytes []byte, homeDir, settingsPath string,
}
placeholder := "__PROMPT_FILE_" + phase + "__"
if prompt, _ := agentMap["prompt"].(string); prompt == placeholder {
agentMap["prompt"] = "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}"
promptRef, err := SharedPromptFileRef(settingsPath, homeDir, phase)
if err != nil {
return nil, fmt.Errorf("build shared prompt file reference for %q: %w", phase, err)
}
agentMap["prompt"] = promptRef
}
}
}
Expand Down
28 changes: 25 additions & 3 deletions internal/components/sdd/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2339,13 +2339,17 @@ func TestInjectOpenCodeSubagentPromptsStayExecutorScoped(t *testing.T) {
t.Fatalf("%s has unexpected type: %T", phase, raw)
}

// After the shared-prompt-files refactor, the prompt field is a {file:...}
// reference. The executor-scoped content lives in the prompt file on disk.
prompt, _ := agentDef["prompt"].(string)
expectedRef := "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}"
expectedRef, err := SharedPromptFileRef(settingsPath, home, phase)
if err != nil {
t.Fatalf("SharedPromptFileRef() error = %v", err)
}
if prompt != expectedRef {
t.Fatalf("%s prompt = %q, want {file:...} reference %q", phase, prompt, expectedRef)
}
if strings.Contains(prompt, filepath.ToSlash(home)) {
t.Fatalf("%s prompt = %q, contains home path", phase, prompt)
}

// Also verify the prompt file contains the executor-scoped content
// (skill content that makes clear this is the executor, not orchestrator).
Expand All @@ -2368,6 +2372,24 @@ func TestInjectOpenCodeSubagentPromptsStayExecutorScoped(t *testing.T) {
}
}

func TestInjectKilocodeSubagentPromptUsesSharedRelativePath(t *testing.T) {
home := t.TempDir()
adapter := kilocodeAdapter()

if _, err := Inject(home, adapter, "multi"); err != nil {
t.Fatalf("Inject(multi) error = %v", err)
}

prompt := agentPrompt(t, readOpenCodeAgents(t, adapter.SettingsPath(home)), "sdd-apply")
want := "{file:../opencode/prompts/sdd/sdd-apply.md}"
if prompt != want {
t.Fatalf("sdd-apply prompt = %q, want %q", prompt, want)
}
if strings.Contains(prompt, filepath.ToSlash(home)) {
t.Fatalf("sdd-apply prompt = %q, contains home path", prompt)
}
}

func TestInjectOpenCodeEmptySDDModeDefaultsSingle(t *testing.T) {
mockNoPackageManager(t)
home := t.TempDir()
Expand Down
8 changes: 5 additions & 3 deletions internal/components/sdd/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func extractModelFromAgent(agentMap map[string]any) model.ModelAssignment {
// sub-agent references and model assignments table), permissions scoped to *-{name}
// - sdd-{phase}-{name} (10 agents): subagent mode, hidden, file reference to
// the shared prompt at SharedPromptDir(homeDir)/sdd-{phase}.md
func GenerateProfileOverlay(profile model.Profile, homeDir string, codeGraphGuidance ...string) ([]byte, error) {
func GenerateProfileOverlay(profile model.Profile, homeDir, settingsPath string, codeGraphGuidance ...string) ([]byte, error) {
if profile.Name == "" || profile.Name == "default" {
return nil, fmt.Errorf("GenerateProfileOverlay: profile name must be non-empty and not 'default'")
}
Expand Down Expand Up @@ -325,7 +325,6 @@ func GenerateProfileOverlay(profile model.Profile, homeDir string, codeGraphGuid
agentMap[orchestratorKey] = orchEntry

// Sub-agent entries
promptDir := SharedPromptDir(homeDir)
phaseDescriptions := map[string]string{
"sdd-init": "Bootstrap SDD context and project configuration",
"sdd-explore": "Investigate codebase and think through ideas",
Expand All @@ -341,7 +340,10 @@ func GenerateProfileOverlay(profile model.Profile, homeDir string, codeGraphGuid

for _, phase := range profilePhaseOrder {
key := phase + suffix
prompt := "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}"
prompt, err := SharedPromptFileRef(settingsPath, homeDir, phase)
if err != nil {
return nil, fmt.Errorf("build shared prompt file reference for %q: %w", phase, err)
}
entry := map[string]any{
"mode": "subagent",
"hidden": true,
Expand Down
8 changes: 4 additions & 4 deletions internal/components/sdd/profiles_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func TestProfileLifecycle_FullCRUD(t *testing.T) {
OrchestratorModel: haikuModel,
}

overlayBytes, err := GenerateProfileOverlay(cheapProfile, home)
overlayBytes, err := GenerateProfileOverlay(cheapProfile, home, settingsPath)
if err != nil {
t.Fatalf("GenerateProfileOverlay(): %v", err)
}
Expand Down Expand Up @@ -121,7 +121,7 @@ func TestProfileLifecycle_FullCRUD(t *testing.T) {
Name: "cheap",
OrchestratorModel: sonnetModel,
}
editOverlayBytes, err := GenerateProfileOverlay(editedProfile, home)
editOverlayBytes, err := GenerateProfileOverlay(editedProfile, home, settingsPath)
if err != nil {
t.Fatalf("GenerateProfileOverlay() for edit: %v", err)
}
Expand Down Expand Up @@ -224,7 +224,7 @@ func TestProfileLifecycle_TwoProfiles(t *testing.T) {
cheapOverlay, err := GenerateProfileOverlay(model.Profile{
Name: "cheap",
OrchestratorModel: model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-haiku-3-5"},
}, home)
}, home, settingsPath)
if err != nil {
t.Fatalf("GenerateProfileOverlay(cheap): %v", err)
}
Expand All @@ -233,7 +233,7 @@ func TestProfileLifecycle_TwoProfiles(t *testing.T) {
premiumOverlay, err := GenerateProfileOverlay(model.Profile{
Name: "premium",
OrchestratorModel: model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-opus-4-5"},
}, home)
}, home, settingsPath)
if err != nil {
t.Fatalf("GenerateProfileOverlay(premium): %v", err)
}
Expand Down
31 changes: 18 additions & 13 deletions internal/components/sdd/profiles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,10 +403,14 @@ func makeHaikuProfile() model.Profile {
}
}

func openCodeSettingsPathForTest(home string) string {
return filepath.Join(home, ".config", "opencode", "opencode.json")
}

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

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -479,7 +483,7 @@ func TestGenerateProfileOverlay_Structure(t *testing.T) {
func TestGenerateProfileOverlay_PermissionScoped(t *testing.T) {
home := t.TempDir()

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -524,7 +528,7 @@ func TestGenerateProfileOverlay_JDAssignmentsGenerateSuffixedAgents(t *testing.T
profile.PhaseAssignments["jd-judge-b"] = model.ModelAssignment{ProviderID: "openai", ModelID: "gpt-5.1"}
profile.PhaseAssignments["jd-fix-agent"] = model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-sonnet-4-20250514"}

overlay, err := GenerateProfileOverlay(profile, home)
overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -597,7 +601,7 @@ func TestGenerateProfileOverlay_JDAssignmentsGenerateSuffixedAgents(t *testing.T
func TestGenerateProfileOverlay_NoJDAssignmentsUsesGlobalJDAgents(t *testing.T) {
home := t.TempDir()

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -632,7 +636,7 @@ func TestGenerateProfileOverlay_NoJDAssignmentsUsesGlobalJDAgents(t *testing.T)
func TestGenerateProfileOverlay_ToolsUseReplaceSentinel(t *testing.T) {
home := t.TempDir()

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -733,7 +737,7 @@ func TestDefaultOverlayToolsUseReplaceSentinel(t *testing.T) {
func TestGenerateProfileOverlay_TaskPermissionsBlockCrossProfileDelegation(t *testing.T) {
home := t.TempDir()

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -771,13 +775,11 @@ func TestGenerateProfileOverlay_TaskPermissionsBlockCrossProfileDelegation(t *te
func TestGenerateProfileOverlay_SubAgentFileRefs(t *testing.T) {
home := t.TempDir()

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}

promptDir := SharedPromptDir(home)

var root map[string]any
if err := json.Unmarshal(overlay, &root); err != nil {
t.Fatalf("overlay is not valid JSON: %v", err)
Expand All @@ -788,7 +790,10 @@ func TestGenerateProfileOverlay_SubAgentFileRefs(t *testing.T) {
key := phase + "-cheap"
agent := agentMap[key].(map[string]any)
prompt, _ := agent["prompt"].(string)
expectedRef := "{file:" + filepath.ToSlash(filepath.Join(promptDir, phase+".md")) + "}"
expectedRef, err := SharedPromptFileRef(openCodeSettingsPathForTest(home), home, phase)
if err != nil {
t.Fatalf("SharedPromptFileRef() error = %v", err)
}
if prompt != expectedRef {
t.Errorf("sub-agent %q prompt = %q, want %q", key, prompt, expectedRef)
}
Expand All @@ -798,7 +803,7 @@ func TestGenerateProfileOverlay_SubAgentFileRefs(t *testing.T) {
func TestGenerateProfileOverlay_OrchestratorPromptSuffixed(t *testing.T) {
home := t.TempDir()

overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home)
overlay, err := GenerateProfileOverlay(makeHaikuProfile(), home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -1106,7 +1111,7 @@ func TestGenerateProfileOverlay_VariantInjected(t *testing.T) {
},
}

overlay, err := GenerateProfileOverlay(profile, home)
overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down Expand Up @@ -1138,7 +1143,7 @@ func TestGenerateProfileOverlay_EmptyEffortClearsVariant(t *testing.T) {
},
}

overlay, err := GenerateProfileOverlay(profile, home)
overlay, err := GenerateProfileOverlay(profile, home, openCodeSettingsPathForTest(home))
if err != nil {
t.Fatalf("GenerateProfileOverlay() error = %v", err)
}
Expand Down
15 changes: 15 additions & 0 deletions internal/components/sdd/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ func SharedPromptDir(homeDir string) string {
return filepath.Join(homeDir, ".config", "opencode", "prompts", "sdd")
}

// SharedPromptFileRef returns a prompt file reference relative to the settings
// file that will contain it.
func SharedPromptFileRef(settingsPath, homeDir, phase string) (string, error) {
promptPath := filepath.Join(SharedPromptDir(homeDir), phase+".md")
relativePath, err := filepath.Rel(filepath.Dir(settingsPath), promptPath)
if err != nil {
return "", err
}
relativePath = filepath.ToSlash(relativePath)
if !strings.HasPrefix(relativePath, ".") {
relativePath = "./" + relativePath
}
return "{file:" + relativePath + "}", nil
}

// subAgentPhaseOrder is an alias for profilePhaseOrder (defined in profiles.go),
// kept for backward compatibility with any code in this file that references it.
// Both variables are in the same package and represent the same canonical list.
Expand Down
Loading
Loading