diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index 87dd0b611c..4b82bf7300 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -297,7 +297,7 @@ func (m *Manager) bootstrapAgent(ctx context.Context, ia agents.InferenceAgent) m.instances[agentType] = inst m.mu.Unlock() - caps := m.probe(ctx, inst, ia) + caps := m.probe(ctx, inst, ia, false) m.cache.set(caps) log.Info("host utility bootstrap completed", zap.String("status", string(caps.Status)), @@ -522,42 +522,22 @@ const probeTimeout = 60 * time.Second // probe runs an ACP probe against the given instance and translates the result // into an AgentCapabilities record suitable for the cache. -func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent) AgentCapabilities { +func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.InferenceAgent, refresh bool) AgentCapabilities { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() - cfg := ia.InferenceConfig() - req := &agentctlutil.ProbeRequest{ - AgentID: inst.agentType, - InferenceConfig: &agentctlutil.InferenceConfigDTO{ - Command: cfg.Command.Args(), - ModelFlag: cfg.ModelFlag.Args(), - WorkDir: inst.workDir, - StripEnv: agents.StripEnvFor(ia), - }, - } + req := buildProbeRequest(inst, ia, refresh) resp, err := inst.client.Probe(probeCtx, req) now := time.Now() if err != nil { - return AgentCapabilities{ - AgentType: inst.agentType, - Status: StatusFailed, - Error: err.Error(), - LastCheckedAt: now, - } + return probeFailureCapabilities(inst.agentType, StatusFailed, err.Error(), 0, now) } if !resp.Success { status := StatusFailed if isAuthError(resp.Error) { status = StatusAuthRequired } - return AgentCapabilities{ - AgentType: inst.agentType, - Status: status, - Error: resp.Error, - DurationMs: resp.DurationMs, - LastCheckedAt: now, - } + return probeFailureCapabilities(inst.agentType, status, resp.Error, resp.DurationMs, now) } caps := AgentCapabilities{ AgentType: inst.agentType, @@ -616,6 +596,40 @@ func (m *Manager) probe(ctx context.Context, inst *instance, ia agents.Inference return caps } +func buildProbeRequest( + inst *instance, + ia agents.InferenceAgent, + refresh bool, +) *agentctlutil.ProbeRequest { + cfg := ia.InferenceConfig() + return &agentctlutil.ProbeRequest{ + AgentID: inst.agentType, + Refresh: refresh, + InferenceConfig: &agentctlutil.InferenceConfigDTO{ + Command: cfg.Command.Args(), + ModelFlag: cfg.ModelFlag.Args(), + WorkDir: inst.workDir, + StripEnv: agents.StripEnvFor(ia), + }, + } +} + +func probeFailureCapabilities( + agentType string, + status Status, + errorMessage string, + durationMs int, + checkedAt time.Time, +) AgentCapabilities { + return AgentCapabilities{ + AgentType: agentType, + Status: status, + Error: errorMessage, + DurationMs: durationMs, + LastCheckedAt: checkedAt, + } +} + // isAuthError is a coarse heuristic — ACP auth failures bubble up as string // errors from the SDK without a distinct code. We match obvious markers so // the UI can route the user to the auth flow; anything unmatched stays as diff --git a/apps/backend/internal/agent/hostutility/manager_test.go b/apps/backend/internal/agent/hostutility/manager_test.go index 4e29786167..d565cbbd9a 100644 --- a/apps/backend/internal/agent/hostutility/manager_test.go +++ b/apps/backend/internal/agent/hostutility/manager_test.go @@ -101,7 +101,7 @@ func TestProbePreservesConfigOptionDescriptions(t *testing.T) { agentType: "codex-acp", workDir: t.TempDir(), client: agentctlclient.NewClient(host, port, log), - }, &installedInferenceAgent{id: "codex-acp"}) + }, &installedInferenceAgent{id: "codex-acp"}, false) raw, err := json.Marshal(caps.ConfigOptions) require.NoError(t, err) @@ -245,6 +245,7 @@ func TestRefreshRecreatesStaleCachedInstance(t *testing.T) { require.NoError(t, reg.Register(&installedInferenceAgent{id: agentType})) var probeCalls atomic.Int32 + var refreshRequested atomic.Bool instanceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/health": @@ -259,6 +260,11 @@ func TestRefreshRecreatesStaleCachedInstance(t *testing.T) { return } probeCalls.Add(1) + var request agentctlutil.ProbeRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode probe request: %v", err) + } + refreshRequested.Store(request.Refresh) w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(agentctlutil.ProbeResponse{ Success: true, @@ -321,6 +327,7 @@ func TestRefreshRecreatesStaleCachedInstance(t *testing.T) { require.Equal(t, int32(1), createCalls.Load()) require.Equal(t, int32(1), deleteCalls.Load()) require.Equal(t, int32(1), probeCalls.Load()) + require.True(t, refreshRequested.Load()) mgr.mu.RLock() fresh := mgr.instances[agentType] diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index 42ccec186c..866722699b 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -50,7 +50,7 @@ func (m *Manager) Refresh(ctx context.Context, agentType string) (AgentCapabilit }) return AgentCapabilities{}, err } - caps := m.probe(ctx, inst, ia) + caps := m.probe(ctx, inst, ia, true) m.cache.set(caps) return caps, nil } diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index d46a7dbf64..f19490ccdc 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -311,6 +311,11 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P } startTime := time.Now() + isOpenCode := isOpenCodeACPCommand(cfg.Command) + var refreshedOpenCodeModels []ProbeModel + if isOpenCode && req.Refresh { + refreshedOpenCodeModels = e.loadOpenCodeModels(ctx, resolvedCmd, workDir, true) + } // Probes intentionally omit the model flag so session/new returns the agent's // default model and the complete availableModels list. @@ -354,8 +359,11 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P DurationMs: int(time.Since(startTime).Milliseconds()), }, nil } - if len(resp.Models) == 0 && isOpenCodeACPCommand(cfg.Command) { - e.applyOpenCodeModelsFallback(ctx, resp, resolvedCmd, workDir) + if len(resp.Models) == 0 && isOpenCode { + if len(refreshedOpenCodeModels) == 0 { + refreshedOpenCodeModels = e.loadOpenCodeModels(ctx, resolvedCmd, workDir, false) + } + resp.Models = refreshedOpenCodeModels } resp.Success = true @@ -371,29 +379,41 @@ func isOpenCodeACPCommand(command []string) bool { command[1] == openCodeACPSubcommand } -// applyOpenCodeModelsFallback fills an otherwise empty probe model list from -// OpenCode's CLI model listing. -func (e *ACPInferenceExecutor) applyOpenCodeModelsFallback(ctx context.Context, resp *ProbeResponse, resolvedCmd, workDir string) { - models, err := probeOpenCodeModels(ctx, resolvedCmd, workDir) +// loadOpenCodeModels runs OpenCode's CLI model listing and logs failures +// without failing the broader ACP capability probe. +func (e *ACPInferenceExecutor) loadOpenCodeModels( + ctx context.Context, + resolvedCmd string, + workDir string, + refresh bool, +) []ProbeModel { + models, err := probeOpenCodeModels(ctx, resolvedCmd, workDir, refresh) if err != nil { e.logger.Warn("ACP probe: failed to list opencode models", zap.String("command", resolvedCmd), zap.Error(err)) - return + return nil } if len(models) == 0 { e.logger.Warn("ACP probe: opencode models returned no valid model entries", zap.String("command", resolvedCmd)) - return } - resp.Models = models + return models } -// probeOpenCodeModels runs the OpenCode model-listing command and converts its -// output into probe model entries. -func probeOpenCodeModels(ctx context.Context, resolvedCmd, workDir string) ([]ProbeModel, error) { +// probeOpenCodeModels lists OpenCode's models, optionally refreshing its cache. +func probeOpenCodeModels( + ctx context.Context, + resolvedCmd string, + workDir string, + refresh bool, +) ([]ProbeModel, error) { + args := []string{"models"} + if refresh { + args = append(args, "--refresh") + } //nolint:gosec // resolvedCmd is from the same hard-coded allow-list used to launch the ACP probe. - cmd := exec.CommandContext(ctx, resolvedCmd, "models") + cmd := exec.CommandContext(ctx, resolvedCmd, args...) cmd.Dir = workDir cmd.Env = environWithNoColor(os.Environ()) out, err := cmd.Output() diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor_process_unix_test.go b/apps/backend/internal/agentctl/server/utility/acp_executor_process_unix_test.go index 40ebdd8e58..c06443c014 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor_process_unix_test.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor_process_unix_test.go @@ -66,6 +66,83 @@ func TestProbeCleansUpDescendantProcessOnTimeout(t *testing.T) { } } +func TestProbeOpenCodeModelsHonorsRefresh(t *testing.T) { + tmp := t.TempDir() + agentPath := filepath.Join(tmp, openCodeCommand) + writeExecutable(t, agentPath, `#!/bin/sh +if [ "$1" = "models" ] && [ "$2" = "--refresh" ]; then + echo "opencode/fresh" +else + echo "opencode/stale" +fi +`) + + cached, err := probeOpenCodeModels(context.Background(), agentPath, tmp, false) + if err != nil { + t.Fatalf("probeOpenCodeModels returned error: %v", err) + } + if len(cached) != 1 || cached[0].ID != "opencode/stale" { + t.Fatalf("cached models = %#v, want cached model list", cached) + } + + refreshed, err := probeOpenCodeModels(context.Background(), agentPath, tmp, true) + if err != nil { + t.Fatalf("refresh probeOpenCodeModels returned error: %v", err) + } + if len(refreshed) != 1 || refreshed[0].ID != "opencode/fresh" { + t.Fatalf("refreshed models = %#v, want refreshed model list", refreshed) + } +} + +func TestProbeRefreshesOpenCodeCacheBeforeACPSession(t *testing.T) { + tmp := t.TempDir() + marker := filepath.Join(tmp, "models-refreshed") + agentPath := filepath.Join(tmp, openCodeCommand) + writeExecutable(t, agentPath, `#!/bin/sh +if [ "$1" = "models" ]; then + if [ "$2" = "--refresh" ]; then + touch "$OPENCODE_REFRESH_MARKER" + fi + echo "opencode/fresh" + exit 0 +fi + +read -r INITIALIZE +INITIALIZE_ID=$(printf '%s' "$INITIALIZE" | sed -n 's/.*"id":\([^,}]*\).*/\1/p') +printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$INITIALIZE_ID" + +read -r NEW_SESSION +SESSION_ID=$(printf '%s' "$NEW_SESSION" | sed -n 's/.*"id":\([^,}]*\).*/\1/p') +MODEL="opencode/stale" +if [ -f "$OPENCODE_REFRESH_MARKER" ]; then + MODEL="opencode/fresh" +fi +printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"test","configOptions":[{"type":"select","id":"model","name":"Model","category":"model","currentValue":"%s","options":[{"value":"%s","name":"%s"}]}]}}\n' "$SESSION_ID" "$MODEL" "$MODEL" "$MODEL" +cat >/dev/null +`) + t.Setenv("PATH", tmp+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("OPENCODE_REFRESH_MARKER", marker) + + executor := NewACPInferenceExecutor(zap.NewNop()) + resp, err := executor.Probe(context.Background(), &ProbeRequest{ + AgentID: "opencode-acp", + Refresh: true, + InferenceConfig: &InferenceConfigDTO{ + Command: []string{openCodeCommand, openCodeACPSubcommand}, + WorkDir: tmp, + }, + }) + if err != nil { + t.Fatalf("Probe returned error: %v", err) + } + if !resp.Success { + t.Fatalf("Probe failed: %s", resp.Error) + } + if len(resp.Models) != 1 || resp.Models[0].ID != "opencode/fresh" { + t.Fatalf("models = %#v, want refreshed ACP model list", resp.Models) + } +} + func TestWaitForACPProcessGroupExitHonorsContextCancellation(t *testing.T) { cmd := exec.Command("sleep", "30") setACPCommandProcAttr(cmd) diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index fe5039fff7..54ab1c5ef2 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -60,6 +60,10 @@ type ProbeRequest struct { // AgentID is the agent to probe (e.g., "claude-acp", "codex-acp"). AgentID string `json:"agent_id" binding:"required"` + // Refresh asks agent-specific discovery fallbacks to invalidate their own + // model caches. ACP session discovery itself always starts a fresh process. + Refresh bool `json:"refresh,omitempty"` + // InferenceConfig is the agent's inference configuration. // Command and WorkDir are required; Model is intentionally omitted for probes. InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"`