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
10 changes: 5 additions & 5 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ jobs:
}

if (failures.length > 0) {
core.setFailed(failures.join('\n\n'));
core.warning(failures.join('\n\n'));
}
Comment on lines 122 to 124

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

Documentation now contradicts actual behavior.

The PR template (.github/PULL_REQUEST_TEMPLATE.md) states that "PRs without a linked approved issue will be automatically rejected by CI." With this change to core.warning, the step no longer blocks the PRβ€”it only emits a warning. Contributors may be misled by documentation that promises rejection.

Additionally, the messages pushed to the failures array (lines 104, 112-116) still use the ❌ prefix, which typically signals a hard failure. Consider updating those to ⚠️ for consistency with the warning-only behavior.

Suggested fix for message consistency
             } catch (err) {
-              failures.push(`❌ Could not fetch issue #${issueNumber}: ${err.message}`);
+              failures.push(`⚠️ Could not fetch issue #${issueNumber}: ${err.message}`);
               continue;
             }

             const labels = issue.labels.map(l => l.name);
             console.log(`Labels on issue #${issueNumber}: ${labels.join(', ') || '(none)'}`);

             if (!labels.includes('status:approved')) {
               failures.push(
-                `❌ Issue #${issueNumber} does not have the "status:approved" label.\n` +
+                `⚠️ Issue #${issueNumber} does not have the "status:approved" label.\n` +
                 '   Issues must be approved by a maintainer before work begins.\n' +
                 '   Please comment on the issue and wait for it to be labelled status:approved.'
               );
🧰 Tools
πŸͺ› zizmor (1.25.2)

[warning] 70-124: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

πŸ€– 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 @.github/workflows/pr-check.yml around lines 122 - 124, The PR template
documentation promises that "PRs without a linked approved issue will be
automatically rejected by CI," but the workflow now only emits a warning via
core.warning instead of blocking the PR. Update the PR template
(`.github/PULL_REQUEST_TEMPLATE.md`) to accurately reflect that missing or
unapproved issue links trigger a warning rather than automatic rejection.
Additionally, for consistency with the warning-only behavior, update the message
prefixes in the failures array (the messages added at lines 104 and 112-116)
from the ❌ prefix to ⚠️ to signal warnings instead of hard failures.


check-type-label:
Expand All @@ -145,16 +145,16 @@ jobs:
console.log(`type:* labels found: ${typeLabels.join(', ') || '(none)'}`);

if (typeLabels.length === 0) {
core.setFailed(
'❌ PR must have exactly one type:* label.\n\n' +
core.warning(
'⚠️ PR must have exactly one type:* label.\n\n' +
'Valid labels:\n' +
' type:bug, type:feature, type:refactor,\n' +
' type:docs, type:chore, type:breaking-change\n\n' +
'Ask a maintainer to add the appropriate label.'
);
} else if (typeLabels.length > 1) {
core.setFailed(
`❌ PR has ${typeLabels.length} type:* labels: ${typeLabels.join(', ')}\n` +
core.warning(
`⚠️ PR has ${typeLabels.length} type:* labels: ${typeLabels.join(', ')}\n` +
'A PR must have exactly ONE type:* label. Please remove the extra one(s).'
);
Comment on lines 147 to 159

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

Documentation claims CI will reject, but check now only warns.

The skill documentation (skills/branch-pr/SKILL.md) states "CI will reject PRs with zero or multiple type labels." This change to core.warning means the check no longer blocks mergingβ€”documentation should be updated to reflect the new warning-only behavior.

πŸ€– 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 @.github/workflows/pr-check.yml around lines 147 - 159, The documentation in
skills/branch-pr/SKILL.md claims that CI will reject PRs with zero or multiple
type:* labels, but the current implementation uses core.warning calls instead of
core.setFailed, meaning the check only warns without blocking merging. Update
the documentation to accurately reflect that the type label validation now
produces warnings rather than hard rejections.

} else {
Expand Down
12 changes: 12 additions & 0 deletions internal/assets/claude/engram-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
You have access to Engram, a persistent memory system that survives across sessions and compactions.
This protocol is MANDATORY and ALWAYS ACTIVE β€” not something you activate on demand.

### SESSION START & PROJECT DETECTION PROTOCOL (mandatory)

At the very beginning of the session, if you have an active workspace directory:
1. **Detect Project Name**: Call `mem_current_project` sending the absolute path of the workspace directory in the `cwd` (or `directory`) parameter.
2. **Start Session**: Call `mem_session_start` with:
- **id**: A unique session ID (e.g., `session-` + conversation ID)
- **directory**: The absolute path of the workspace directory. Do NOT let Engram guess the project automatically based on the global execution context, as this leads to project-session name mismatches (such as misdetecting the global agent's directory instead of the target workspace project).
3. **Persist State**: Store the resolved project name and the session ID in your active context. You MUST:
- Use the session ID for all mutation tools (`mem_save`, `mem_session_summary`, `mem_session_end`, `mem_capture_passive`).
- Use the project name for all read/search/diagnostic tools (`mem_search`, `mem_context`, `mem_doctor`).
Comment on lines +13 to +15

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

Include mem_save_prompt in the session-id propagation contract.

mem_save_prompt records the user's prompt and feeds SessionActivity, so it needs the same session ID to keep prompt capture bound to the correct project/session. Leaving it out here creates a hole in the new session-start flow even though mem_save now requires session_id.

♻️ Proposed fix
-   - Use the session ID for all mutation tools (`mem_save`, `mem_session_summary`, `mem_session_end`, `mem_capture_passive`).
+   - Use the session ID for all mutation tools (`mem_save`, `mem_save_prompt`, `mem_session_summary`, `mem_session_end`, `mem_capture_passive`).
πŸ“ 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
3. **Persist State**: Store the resolved project name and the session ID in your active context. You MUST:
- Use the session ID for all mutation tools (`mem_save`, `mem_session_summary`, `mem_session_end`, `mem_capture_passive`).
- Use the project name for all read/search/diagnostic tools (`mem_search`, `mem_context`, `mem_doctor`).
3. **Persist State**: Store the resolved project name and the session ID in your active context. You MUST:
- Use the session ID for all mutation tools (`mem_save`, `mem_save_prompt`, `mem_session_summary`, `mem_session_end`, `mem_capture_passive`).
- Use the project name for all read/search/diagnostic tools (`mem_search`, `mem_context`, `mem_doctor`).
πŸ€– 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/assets/claude/engram-protocol.md` around lines 13 - 15, The list of
mutation tools that require session_id in the Persist State section is
incomplete. Add mem_save_prompt to the comma-separated list of mutation tools
that must use session_id (currently showing mem_save, mem_session_summary,
mem_session_end, mem_capture_passive) since mem_save_prompt also records user
prompts and feeds SessionActivity, so it must receive and use the same session
ID to maintain consistency in the session-start flow contract.


### PROACTIVE SAVE TRIGGERS (mandatory β€” do NOT wait for user to ask)

Call `mem_save` IMMEDIATELY and WITHOUT BEING ASKED after any of these:
Expand All @@ -22,6 +33,7 @@ Call `mem_save` IMMEDIATELY and WITHOUT BEING ASKED after any of these:
Self-check after EVERY task: "Did I make a decision, fix a bug, learn something non-obvious, or establish a convention? If yes, call mem_save NOW."

Format for `mem_save`:
- **session_id**: The active session ID created at the start (required to associate memory with the correct project)
- **title**: Verb + what β€” short, searchable (e.g. "Fixed N+1 query in UserList")
- **type**: bugfix | decision | architecture | discovery | pattern | config | preference
- **scope**: `project` (default) | `personal`
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/run_engram_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -214,6 +215,9 @@ func TestRunInstallBetaEngramUsesMainGoInstallAndInstalledBinary(t *testing.T) {
home := t.TempDir()
gobin := filepath.Join(home, "go-bin")
betaEngram := filepath.Join(gobin, "engram")
if runtime.GOOS == "windows" {
betaEngram = filepath.Join(gobin, "engram.exe")
}

restoreCommand := runCommand
restoreLookPath := cmdLookPath
Expand Down
56 changes: 49 additions & 7 deletions internal/cli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/gentleman-programming/gentle-ai/internal/components/skills"
"github.com/gentleman-programming/gentle-ai/internal/components/theme"
"github.com/gentleman-programming/gentle-ai/internal/model"
"github.com/gentleman-programming/gentle-ai/internal/opencode"
"github.com/gentleman-programming/gentle-ai/internal/pipeline"
"github.com/gentleman-programming/gentle-ai/internal/state"
"github.com/gentleman-programming/gentle-ai/internal/verify"
Expand Down Expand Up @@ -606,14 +607,15 @@ func (s componentSyncStep) Run() error {
// from disk so their orchestrator prompts are refreshed from updated embedded
// assets while model assignments are preserved.
profiles := s.selection.Profiles
if len(profiles) == 0 && profileStrategy != model.SDDProfileStrategyExternalSingleActive {
settingsPath := ""
for _, adapter := range adapters {
if adapter.Agent() == model.AgentOpenCode {
settingsPath = adapter.SettingsPath(s.homeDir)
break
}
settingsPath := ""
for _, adapter := range adapters {
if adapter.Agent() == model.AgentOpenCode {
settingsPath = adapter.SettingsPath(s.homeDir)
break
}
}

if len(profiles) == 0 && profileStrategy != model.SDDProfileStrategyExternalSingleActive {
if settingsPath != "" {
detected, detectErr := sdd.DetectProfiles(settingsPath)
if detectErr == nil {
Expand All @@ -623,6 +625,46 @@ func (s componentSyncStep) Run() error {
}
}

// R-PROF-31: Validate profile model assignments against OpenCode model cache
if len(profiles) > 0 && settingsPath != "" {
cachePath := opencode.DefaultCachePath()
if cachePath != "" {
if providers, err := opencode.LoadModelsOrEmpty(cachePath); err == nil {
// Merge custom providers if defined
Comment on lines +630 to +633

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

Use the sync target home for model-cache lookup.

At Line 630, opencode.DefaultCachePath() resolves from os.UserHomeDir() instead of s.homeDir. That breaks RunSyncWithSelection(homeDir, ...) isolation and can validate against the wrong cache (or host cache), yielding incorrect/misleading warnings.

Proposed fix
-           cachePath := opencode.DefaultCachePath()
+           cachePath := filepath.Join(s.homeDir, ".cache", "opencode", "models.json")
            if cachePath != "" {
                if providers, err := opencode.LoadModelsOrEmpty(cachePath); err == nil {

This also explains why the new regression test can be environment-dependent unless runtime uses s.homeDir here.

πŸ€– 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/cli/sync.go` around lines 630 - 633, The cachePath assignment at
line 630 uses opencode.DefaultCachePath() which resolves from the operating
system's user home directory instead of the sync target's home directory stored
in s.homeDir. Replace the call to opencode.DefaultCachePath() with a function
call that constructs the cache path using s.homeDir instead, ensuring that
model-cache lookups use the correct isolated home directory for the sync target.
This will fix the test isolation issue and ensure warnings are validated against
the correct cache rather than the host cache.

configProviders, configErr := opencode.LoadConfigProviders(settingsPath)
if configErr == nil && len(configProviders) > 0 {
providers = opencode.MergeCustomProviders(providers, configProviders)
}

// Build a map of valid full model IDs: "provider/model"
validModels := make(map[string]bool)
for provID, prov := range providers {
for modelID := range prov.Models {
validModels[provID+"/"+modelID] = true
}
}

// Check each profile
for _, p := range profiles {
if p.OrchestratorModel.ProviderID != "" && p.OrchestratorModel.ModelID != "" {
fullID := p.OrchestratorModel.FullID()
if !validModels[fullID] {
fmt.Fprintf(os.Stderr, "WARNING: model %q assigned to orchestrator in profile %q not found in OpenCode model cache\n", fullID, p.Name)
}
}
for phase, assignment := range p.PhaseAssignments {
if assignment.ProviderID != "" && assignment.ModelID != "" {
fullID := assignment.FullID()
if !validModels[fullID] {
fmt.Fprintf(os.Stderr, "WARNING: model %q assigned to phase %q in profile %q not found in OpenCode model cache\n", fullID, phase, p.Name)
}
}
}
}
}
}
}

// If profiles exist (explicit or detected), SDDModeMulti is required:
// shared prompt files must be written and {file:...} refs must resolve.
sddMode := s.selection.SDDMode
Expand Down
96 changes: 96 additions & 0 deletions internal/cli/sync_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cli

import (
"io"
"os"
"path/filepath"
"reflect"
Expand Down Expand Up @@ -2944,3 +2945,98 @@ func TestRunSync_RestoresCodexPhaseModelAssignments(t *testing.T) {
t.Fatalf("AGENTS.md rendered carril table instead of Custom per-phase table; got:\n%s", text)
}
}

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

// Create opencode.json settings with a profile containing an invalid model
settingsDir := filepath.Join(home, ".config", "opencode")
if err := os.MkdirAll(settingsDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
opencodeJSON := `{
"agent": {
"sdd-orchestrator-test-profile": {
"mode": "primary",
"model": "nonexistent/invalid-model"
}
}
}`
if err := os.WriteFile(filepath.Join(settingsDir, "opencode.json"), []byte(opencodeJSON), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

// Create models.json cache with a single valid model
cacheDir := filepath.Join(home, ".cache", "opencode")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
modelsJSON := `{
"anthropic": {
"name": "Anthropic",
"models": {
"claude-sonnet-4": {
"id": "claude-sonnet-4",
"name": "Claude 4 Sonnet",
"tool_call": true
}
}
}
}`
if err := os.WriteFile(filepath.Join(cacheDir, "models.json"), []byte(modelsJSON), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}

// Setup mocks
restoreHome := osUserHomeDir
restoreCommand := runCommand
restoreLookPath := cmdLookPath
t.Cleanup(func() {
osUserHomeDir = restoreHome
runCommand = restoreCommand
cmdLookPath = restoreLookPath
})
osUserHomeDir = func() (string, error) { return home, nil }
runCommand = func(string, ...string) error { return nil }
cmdLookPath = func(name string) (string, error) { return "/usr/local/bin/" + name, nil }

// Capture stderr
oldStderr := os.Stderr
r, w, pipeErr := os.Pipe()
if pipeErr != nil {
t.Fatalf("os.Pipe: %v", pipeErr)
}
os.Stderr = w

sel := model.Selection{
Agents: []model.AgentID{model.AgentOpenCode},
Components: []model.ComponentID{
model.ComponentSDD,
model.ComponentEngram,
model.ComponentContext7,
model.ComponentGGA,
model.ComponentSkills,
model.ComponentPersona,
},
SDDMode: model.SDDModeMulti,
}
_, syncErr := RunSyncWithSelection(home, sel)

// Restore stderr
w.Close()
os.Stderr = oldStderr
if syncErr != nil {
t.Fatalf("RunSyncWithSelection() error = %v", syncErr)
}

var buf strings.Builder
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy: %v", err)
}
output := buf.String()

expectedWarning := `WARNING: model "nonexistent/invalid-model" assigned to orchestrator in profile "test-profile" not found in OpenCode model cache`
if !strings.Contains(output, expectedWarning) {
t.Errorf("expected warning message to contain %q; got:\n%s", expectedWarning, output)
}
}
18 changes: 14 additions & 4 deletions internal/components/engram/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1205,7 +1205,7 @@ func TestEngramGoInstallFromMain_UsesGoEnvForBinDir(t *testing.T) {
t.Fatalf("engramGoInstallFromMain: unexpected error: %v", err)
}

wantDir := fakeInstallDir
wantDir := filepath.Clean(fakeInstallDir)
gotDir := filepath.Dir(binaryPath)
if gotDir != wantDir {
t.Errorf("binary dir = %q, want %q (from go env GOBIN)", gotDir, wantDir)
Expand All @@ -1216,9 +1216,19 @@ func TestEngramGoInstallFromMain_BypassesPublicGoProxy(t *testing.T) {
binDir := t.TempDir()
goPath := filepath.Join(binDir, "go")
recordPath := filepath.Join(t.TempDir(), "go-env.txt")
fakeGo := filepath.Join(binDir, "go")
script := "#!/usr/bin/env bash\n" +
"printf 'GONOSUMDB=%s\\nGOPRIVATE=%s\\nGONOPROXY=%s\\n' \"${GONOSUMDB:-}\" \"${GOPRIVATE:-}\" \"${GONOPROXY:-}\" > \"$GO_ENV_RECORD\"\n"
var fakeGo string
var script string
if runtime.GOOS == "windows" {
fakeGo = filepath.Join(binDir, "go.bat")
script = "@echo off\r\n" +
"echo GONOSUMDB=%GONOSUMDB% > \"%GO_ENV_RECORD%\"\r\n" +
"echo GOPRIVATE=%GOPRIVATE% >> \"%GO_ENV_RECORD%\"\r\n" +
"echo GONOPROXY=%GONOPROXY% >> \"%GO_ENV_RECORD%\"\r\n"
} else {
fakeGo = filepath.Join(binDir, "go")
script = "#!/usr/bin/env bash\n" +
"printf 'GONOSUMDB=%s\\nGOPRIVATE=%s\\nGONOPROXY=%s\\n' \"${GONOSUMDB:-}\" \"${GOPRIVATE:-}\" \"${GONOPROXY:-}\" > \"$GO_ENV_RECORD\"\n"
}
if err := os.WriteFile(fakeGo, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/components/gga/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ func TestBuildConfigDifferentProviders(t *testing.T) {

func TestInjectWritesConfigAndAgents(t *testing.T) {
home := t.TempDir()
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))

result, err := Inject(home, []model.AgentID{model.AgentClaudeCode})
if err != nil {
Expand Down Expand Up @@ -162,6 +163,7 @@ func TestInjectWritesConfigAndAgents(t *testing.T) {

func TestInjectIsIdempotent(t *testing.T) {
home := t.TempDir()
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))

first, err := Inject(home, []model.AgentID{model.AgentOpenCode})
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/components/sdd/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4090,7 +4090,7 @@ func TestInjectCodexWritesSDDOrchestratorAndSkills(t *testing.T) {
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", extractedSkillPath, err)
}
if !strings.HasPrefix(string(extractedSkill), "---\n") {
if !strings.HasPrefix(string(extractedSkill), "---\n") && !strings.HasPrefix(string(extractedSkill), "---\r\n") {
t.Fatalf("Codex SDD skill must start with YAML frontmatter delimiter, got prefix %q", string(extractedSkill[:min(len(extractedSkill), 16)]))
}

Expand Down
2 changes: 1 addition & 1 deletion internal/components/skills/inject_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ func TestInjectWithCapability_WritesExtractedSDDSkillWithFrontmatterAtStart(t *t
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !strings.HasPrefix(string(content), "---\n") {
if !strings.HasPrefix(string(content), "---\n") && !strings.HasPrefix(string(content), "---\r\n") {
t.Fatalf("extracted SDD skill must start with YAML frontmatter delimiter, got prefix %q", string(content[:min(len(content), 16)]))
}
}
Expand Down
10 changes: 10 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4021,6 +4021,16 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) {
// Model assignment picker: orchestrator + all sub-agent phases in one screen.
// Reuse the same enter-on-row logic as ScreenModelPicker.
// Profile creation uses filtered rows (no JD agents).
cachePath := opencode.DefaultCachePath()
if _, err := osStatModelCache(cachePath); err != nil {
if m.ProfileEditMode {
m.setScreen(ScreenProfiles)
} else {
m.ProfileCreateStep = 0
m.Cursor = 0
}
return m, nil
}
Comment on lines +4024 to +4033

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 | πŸ”΄ Critical | ⚑ Quick win

Cache existence check does not match screen-rendering logic.

The cache guard checks whether the file exists (osStatModelCache), but the screen rendering in model_picker.go shows the warning when len(state.AvailableIDs) == 0. When the cache file exists but is corrupt or has no tool-call-capable providers, osStatModelCache succeeds but AvailableIDs is empty. The user sees the warning screen with a single "← Back" option (cursor = 0), presses enter, and this check passesβ€”so the code proceeds to the cursor-based row logic. Since m.Cursor (0) < len(rows) (12), the flow enters provider selection mode with no providers available, leaving the user in a broken state.

πŸ› Proposed fix: check AvailableIDs instead of file existence
-		cachePath := opencode.DefaultCachePath()
-		if _, err := osStatModelCache(cachePath); err != nil {
+		if len(m.ModelPicker.AvailableIDs) == 0 {
 			if m.ProfileEditMode {
 				m.setScreen(ScreenProfiles)
 			} else {
πŸ€– 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/tui/model.go` around lines 4024 - 4033, The cache existence check
using osStatModelCache does not align with the actual condition that determines
whether providers are available. Instead of checking if the cache file exists,
check whether AvailableIDs contains any tool-call-capable providers by verifying
that len(m.AvailableIDs) is greater than zero. This ensures the guard logic
matches the screen-rendering logic in model_picker.go that also checks
len(state.AvailableIDs) == 0 to determine if the warning screen should be shown.
Replace the osStatModelCache call with a direct check on the m.AvailableIDs
field to prevent the user from entering provider selection mode when no
providers are available.

rows := screens.ModelPickerRowsForProfile()
if m.Cursor < len(rows) {
// Enter sub-selection: pick provider then model.
Expand Down
10 changes: 8 additions & 2 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,10 @@ func sddMultiCursor(t *testing.T) int {
// opencode.json and otherwise shows its explicit empty state instead of silently
// skipping model assignment.
func TestSDDModeMultiShowsModelPickerWhenCacheMissing(t *testing.T) {
t.Setenv("HOME", t.TempDir())
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))

m := NewModel(system.DetectionResult{}, "dev")
m.Screen = ScreenSDDMode
Expand All @@ -797,7 +800,10 @@ func TestSDDModeMultiShowsModelPickerWhenCacheMissing(t *testing.T) {
}

func TestSDDModeMultiEmptyModelPickerCanContinueWithDefaults(t *testing.T) {
t.Setenv("HOME", t.TempDir())
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))

m := NewModel(system.DetectionResult{}, "dev")
m.Screen = ScreenSDDMode
Expand Down
10 changes: 10 additions & 0 deletions internal/tui/screens/model_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,16 @@ func renderPhaseList(
}

if len(state.AvailableIDs) == 0 {
if state.ForProfile {
b.WriteString(styles.WarningStyle.Render("OpenCode has not been run yet β€” model cache not found."))
b.WriteString("\n")
b.WriteString(styles.SubtextStyle.Render("Run OpenCode at least once to populate the model cache."))
b.WriteString("\n\n")
b.WriteString(renderOptions([]string{"← Back"}, cursor))
b.WriteString("\n")
b.WriteString(styles.HelpStyle.Render("enter: confirm β€’ esc: back"))
return b.String()
}
b.WriteString(styles.WarningStyle.Render("OpenCode has not been run yet β€” model cache not found."))
b.WriteString("\n")
b.WriteString(styles.SubtextStyle.Render("Run 'opencode' once, then re-run 'gentle-ai sync' to assign models."))
Expand Down
4 changes: 2 additions & 2 deletions internal/update/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -876,9 +876,9 @@ func TestCheckSingleTool_EngramUsesBinaryReleaseChannel(t *testing.T) {
}
execCommand = func(name string, args ...string) *exec.Cmd {
if name == "engram" {
return exec.Command("echo", "engram 1.15.13")
return mockCmd("echo", "engram 1.15.13")
}
return exec.Command("false")
return mockCmd("false")
}

result := checkSingleTool(context.Background(), Tools[1], "dev", system.PlatformProfile{OS: "darwin", PackageManager: "brew", Supported: true})
Expand Down
Loading
Loading