fix(agent): refresh opencode model cache (#1830)

This commit is contained in:
Carlos Florêncio
2026-07-21 08:39:42 +01:00
committed by GitHub
parent ea28ab0e7a
commit d7c24184cd
6 changed files with 162 additions and 40 deletions
@@ -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
@@ -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]
@@ -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
}
@@ -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()
@@ -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)
@@ -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"`