fix(agents): relax OpenCode install detection (#1967)
This commit is contained in:
@@ -16,11 +16,10 @@ part of `AgentCommand`, which existing lifecycle diagnostics log as
|
||||
OpenCode intentionally uses the direct `opencode acp` command for discovery,
|
||||
normal sessions, container commands, and one-shot inference. This keeps startup
|
||||
offline-compatible and ensures discovery validates the executable actually
|
||||
launched. Its installer remains pinned to `opencode-ai@1.18.4`, and discovery
|
||||
runs that executable with `--version` before reporting it as supported. Missing,
|
||||
malformed, failing, or mismatched version output reports the agent as unavailable
|
||||
with the pinned install command as remediation. The ACP initialize response
|
||||
separately records the runtime-reported agent name and version.
|
||||
launched. Its installer remains pinned to `opencode-ai@1.18.4`, but discovery
|
||||
accepts any `opencode` executable found on `PATH`; the ACP probe surfaces auth
|
||||
or protocol-compatibility failures. The ACP initialize response separately
|
||||
records the runtime-reported agent name and version.
|
||||
|
||||
To update a bridge, change one version constant at a time, confirm the exact
|
||||
version exists in the configured npm registry, capture only sanitized ACP wire
|
||||
|
||||
@@ -3,10 +3,6 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
||||
@@ -24,11 +20,6 @@ const (
|
||||
opencodeACPPackage = "opencode-ai"
|
||||
opencodeACPVersion = "1.18.4"
|
||||
opencodeACPPackageSpec = opencodeACPPackage + "@" + opencodeACPVersion
|
||||
opencodeVersionTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var opencodeVersionPattern = regexp.MustCompile(
|
||||
`(?:^|[^0-9A-Za-z])[vV]?([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:$|[^0-9A-Za-z.+-])`,
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -83,25 +74,12 @@ func (a *OpenCodeACP) Logo(v LogoVariant) []byte {
|
||||
}
|
||||
|
||||
func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
// Check for the opencode CLI on PATH. Auth state is surfaced later by
|
||||
// the ACP probe, not by scanning ~/.opencode.
|
||||
// Check for the opencode CLI on PATH. Any installed version is eligible
|
||||
// for the ACP probe; auth and protocol compatibility are surfaced there.
|
||||
result, err := Detect(ctx, WithCommand("opencode"))
|
||||
if err != nil || !result.Available {
|
||||
return result, err
|
||||
}
|
||||
version, err := probeOpenCodeVersion(ctx, result.MatchedPath)
|
||||
if err != nil {
|
||||
return unsupportedOpenCodeResult(result.MatchedPath), fmt.Errorf(
|
||||
"cannot verify OpenCode %s at %q: %w; reinstall with `%s`",
|
||||
opencodeACPVersion, result.MatchedPath, err, a.InstallScript(),
|
||||
)
|
||||
}
|
||||
if version != opencodeACPVersion {
|
||||
return unsupportedOpenCodeResult(result.MatchedPath), fmt.Errorf(
|
||||
"unsupported OpenCode version %s at %q; Kandev requires %s; reinstall with `%s`",
|
||||
version, result.MatchedPath, opencodeACPVersion, a.InstallScript(),
|
||||
)
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
@@ -109,28 +87,6 @@ func (a *OpenCodeACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func probeOpenCodeVersion(ctx context.Context, path string) (string, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, opencodeVersionTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, err := exec.CommandContext(probeCtx, path, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
if probeCtx.Err() != nil {
|
||||
return "", probeCtx.Err()
|
||||
}
|
||||
return "", fmt.Errorf("run --version: %w", err)
|
||||
}
|
||||
match := opencodeVersionPattern.FindStringSubmatch(strings.TrimSpace(string(output)))
|
||||
if len(match) != 2 {
|
||||
return "", fmt.Errorf("parse --version output %q", strings.TrimSpace(string(output)))
|
||||
}
|
||||
return match[1], nil
|
||||
}
|
||||
|
||||
func unsupportedOpenCodeResult(path string) *DiscoveryResult {
|
||||
return &DiscoveryResult{MatchedPath: path}
|
||||
}
|
||||
|
||||
func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd("opencode", "acp").Build()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -29,7 +27,7 @@ func TestOpenCodeACPUsesInstalledBinaryAndPinnedInstaller(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpenCodeACPDiscoveryMatchesRuntimeExecutable(t *testing.T) {
|
||||
binaryPath := writeOpenCodeTestBinary(t, "printf '1.18.4\\n'")
|
||||
binaryPath := writeOpenCodeTestBinary(t, "exit 7")
|
||||
|
||||
a := NewOpenCodeACP()
|
||||
result, err := a.IsInstalled(context.Background())
|
||||
@@ -47,93 +45,15 @@ func TestOpenCodeACPDiscoveryMatchesRuntimeExecutable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeACPDiscoveryAcceptsSupportedVersionOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
}{
|
||||
{name: "exact", output: "1.18.4"},
|
||||
{name: "prefixed and whitespace", output: " OpenCode version v1.18.4 "},
|
||||
{name: "slash prefix", output: "opencode/1.18.4 linux-x64"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
writeOpenCodeTestBinary(t, "printf '%s\\n' \"$OPEN_CODE_VERSION_OUTPUT\"")
|
||||
t.Setenv("OPEN_CODE_VERSION_OUTPUT", tt.output)
|
||||
|
||||
result, err := NewOpenCodeACP().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled() error = %v", err)
|
||||
}
|
||||
if !result.Available {
|
||||
t.Fatal("IsInstalled() Available = false, want true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeACPDiscoveryRejectsUnsupportedVersion(t *testing.T) {
|
||||
binaryPath := writeOpenCodeTestBinary(t, "printf '1.16.2\\n'")
|
||||
|
||||
result, err := NewOpenCodeACP().IsInstalled(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("IsInstalled() error = nil, want version mismatch")
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("IsInstalled() Available = true, want false")
|
||||
}
|
||||
if result.MatchedPath != binaryPath {
|
||||
t.Fatalf("MatchedPath = %q, want %q", result.MatchedPath, binaryPath)
|
||||
}
|
||||
for _, want := range []string{"1.16.2", "requires 1.18.4", "npm install -g opencode-ai@1.18.4"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not contain %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeACPDiscoveryRejectsMalformedVersion(t *testing.T) {
|
||||
writeOpenCodeTestBinary(t, "printf 'OpenCode development build\\n'")
|
||||
|
||||
result, err := NewOpenCodeACP().IsInstalled(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("IsInstalled() error = nil, want parse failure")
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("IsInstalled() Available = true, want false")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "npm install -g opencode-ai@1.18.4") {
|
||||
t.Fatalf("error %q does not contain pinned remediation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeACPDiscoveryHandlesVersionCommandFailure(t *testing.T) {
|
||||
func TestOpenCodeACPDiscoveryDoesNotRunVersionCommand(t *testing.T) {
|
||||
writeOpenCodeTestBinary(t, "exit 7")
|
||||
|
||||
result, err := NewOpenCodeACP().IsInstalled(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("IsInstalled() error = nil, want command failure")
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled() error = %v", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("IsInstalled() Available = true, want false")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "npm install -g opencode-ai@1.18.4") {
|
||||
t.Fatalf("error %q does not contain pinned remediation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeACPDiscoveryHonorsCancellation(t *testing.T) {
|
||||
writeOpenCodeTestBinary(t, "/bin/sleep 10")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result, err := NewOpenCodeACP().IsInstalled(ctx)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("IsInstalled() error = %v, want context.Canceled", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("IsInstalled() Available = true, want false")
|
||||
if !result.Available {
|
||||
t.Fatal("IsInstalled() Available = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user