feat(v0.6): internal/agent package — Resolve + BuiltinProfiles
Pure resolution logic combining a workspace's AgentSpec with the user-level default_agent into a Resolved value carrying Command, Args, and Env. No I/O — PATH lookup stays in shell.ExecAgent and ctask agents check, so Resolve is trivially testable and reusable. BuiltinProfiles enumerates claude and opencode; "custom" is the escape hatch and requires command. Keep BuiltinProfiles in sync with workspace.knownAgentTypes and workspace.IsBuiltinAgentType (Task 1).
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
// Profile describes a built-in agent. The Default command is what ctask
|
||||
// invokes when AgentSpec.Command is empty; Type is the canonical name.
|
||||
type Profile struct {
|
||||
Type string
|
||||
Default string
|
||||
}
|
||||
|
||||
// BuiltinProfiles enumerates the v0.6 built-in agents. "custom" is NOT
|
||||
// in this map — it is the escape hatch with no defaults. Keep this in
|
||||
// sync with workspace.knownAgentTypes AND workspace.IsBuiltinAgentType
|
||||
// (update all three together when a new built-in lands).
|
||||
var BuiltinProfiles = map[string]Profile{
|
||||
"claude": {Type: "claude", Default: "claude"},
|
||||
"opencode": {Type: "opencode", Default: "opencode"},
|
||||
}
|
||||
|
||||
// IsKnownType reports whether t is "claude", "opencode", or "custom".
|
||||
func IsKnownType(t string) bool {
|
||||
if t == "custom" {
|
||||
return true
|
||||
}
|
||||
_, ok := BuiltinProfiles[t]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Resolved is the launch-ready agent value: a single executable name or
|
||||
// path, optional arguments, and an env-var map merged AFTER ctask's own
|
||||
// exported vars at launch time.
|
||||
type Resolved struct {
|
||||
Type string
|
||||
Command string
|
||||
Args []string
|
||||
Env map[string]string
|
||||
}
|
||||
|
||||
// Resolve combines a workspace's AgentSpec with the user-level
|
||||
// default_agent into a launch-ready Resolved. Resolution rules (per
|
||||
// v0.6 spec §5):
|
||||
//
|
||||
// 1. If spec.Type is empty, fall through to defaultAgent.
|
||||
// 2. If the resolved type is "custom", spec.Command is required.
|
||||
// 3. Otherwise, the resolved command is spec.Command if set,
|
||||
// else BuiltinProfiles[type].Default.
|
||||
// 4. Args and Env are carried verbatim from the spec.
|
||||
//
|
||||
// Resolve does NOT call exec.LookPath. PATH validation is the launch
|
||||
// path's job (shell.ExecAgent fails fast with a diagnostic) and
|
||||
// `ctask agents check`'s job. Keeping Resolve I/O-free makes it
|
||||
// trivially testable and reusable.
|
||||
func Resolve(spec workspace.AgentSpec, defaultAgent string) (*Resolved, error) {
|
||||
typ := spec.Type
|
||||
if typ == "" {
|
||||
typ = defaultAgent
|
||||
}
|
||||
if !IsKnownType(typ) {
|
||||
return nil, fmt.Errorf("agent: unknown type %q (must be claude, opencode, or custom)", typ)
|
||||
}
|
||||
|
||||
cmd := spec.Command
|
||||
if cmd == "" {
|
||||
if typ == "custom" {
|
||||
return nil, fmt.Errorf("agent: type \"custom\" requires command field")
|
||||
}
|
||||
cmd = BuiltinProfiles[typ].Default
|
||||
}
|
||||
|
||||
return &Resolved{
|
||||
Type: typ,
|
||||
Command: cmd,
|
||||
Args: spec.Args,
|
||||
Env: spec.Env,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/workspace"
|
||||
)
|
||||
|
||||
func TestResolveBuiltinClaude(t *testing.T) {
|
||||
spec := workspace.AgentSpec{Type: "claude"}
|
||||
got, err := Resolve(spec, "claude")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.Type != "claude" || got.Command != "claude" {
|
||||
t.Errorf("got %+v, want type=claude command=claude", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBuiltinOpencode(t *testing.T) {
|
||||
spec := workspace.AgentSpec{Type: "opencode"}
|
||||
got, err := Resolve(spec, "claude") // default doesn't matter; spec wins
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.Command != "opencode" {
|
||||
t.Errorf("Command = %q, want opencode", got.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCustomRequiresCommand(t *testing.T) {
|
||||
spec := workspace.AgentSpec{Type: "custom"}
|
||||
_, err := Resolve(spec, "claude")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires command") {
|
||||
t.Fatalf("got %v, want error mentioning 'requires command'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCustomWithCommand(t *testing.T) {
|
||||
spec := workspace.AgentSpec{Type: "custom", Command: "my-agent"}
|
||||
got, err := Resolve(spec, "claude")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.Command != "my-agent" {
|
||||
t.Errorf("Command = %q, want my-agent", got.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFallbackToDefaultWhenTypeMissing(t *testing.T) {
|
||||
spec := workspace.AgentSpec{} // legacy / new-without-type
|
||||
got, err := Resolve(spec, "opencode")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.Type != "opencode" || got.Command != "opencode" {
|
||||
t.Errorf("got %+v, want type=opencode command=opencode", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCommandOverride(t *testing.T) {
|
||||
spec := workspace.AgentSpec{Type: "claude", Command: "/opt/claude"}
|
||||
got, err := Resolve(spec, "claude")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if got.Command != "/opt/claude" {
|
||||
t.Errorf("Command = %q, want /opt/claude", got.Command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveArgsAndEnvCarriedThrough(t *testing.T) {
|
||||
spec := workspace.AgentSpec{
|
||||
Type: "opencode",
|
||||
Args: []string{"--model", "x"},
|
||||
Env: map[string]string{"K": "v"},
|
||||
}
|
||||
got, err := Resolve(spec, "claude")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(got.Args) != 2 || got.Args[0] != "--model" || got.Args[1] != "x" {
|
||||
t.Errorf("Args = %v, want [--model x]", got.Args)
|
||||
}
|
||||
if got.Env["K"] != "v" {
|
||||
t.Errorf("Env[K] = %q, want v", got.Env["K"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUnknownDefaultRejected(t *testing.T) {
|
||||
spec := workspace.AgentSpec{}
|
||||
_, err := Resolve(spec, "gemini")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown") {
|
||||
t.Fatalf("got %v, want unknown-type error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsKnownType(t *testing.T) {
|
||||
for _, name := range []string{"claude", "opencode", "custom"} {
|
||||
if !IsKnownType(name) {
|
||||
t.Errorf("IsKnownType(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
if IsKnownType("gemini") {
|
||||
t.Error("IsKnownType(gemini) = true, want false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user