Files
kandev/apps/backend/internal/agent/runtime/lifecycle/utility_test.go
T
2026-08-12 23:46:51 +01:00

456 lines
17 KiB
Go

package lifecycle
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agentctl/server/utility"
)
// inferenceTestAgent is an agent whose inference support is controlled by the
// test, so both the "supported" and "declared but unsupported" branches are
// reachable without depending on a real CLI.
type inferenceTestAgent struct {
*agents.MockAgent
id string
installed bool
config *agents.InferenceConfig
stripEnv []string
}
func (a *inferenceTestAgent) ID() string { return a.id }
func (a *inferenceTestAgent) Name() string { return a.id + "-name" }
func (a *inferenceTestAgent) DisplayName() string { return a.id + "-display" }
func (a *inferenceTestAgent) InferenceConfig() *agents.InferenceConfig { return a.config }
func (a *inferenceTestAgent) Runtime() *agents.RuntimeConfig {
return &agents.RuntimeConfig{StripEnv: a.stripEnv}
}
func (a *inferenceTestAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) {
if !a.installed {
return &agents.DiscoveryResult{Available: false}, nil
}
return &agents.DiscoveryResult{Available: true}, nil
}
var (
_ agents.Agent = (*inferenceTestAgent)(nil)
_ agents.InferenceAgent = (*inferenceTestAgent)(nil)
)
func newInferenceAgent(id string, supported, installed bool) *inferenceTestAgent {
agent := &inferenceTestAgent{MockAgent: agents.NewMockAgent(), id: id, installed: installed}
agent.SetEnabled(true)
if supported {
agent.config = &agents.InferenceConfig{
Supported: true,
Command: agents.NewCommand("npx", "-y", "@acp/"+id),
ModelFlag: agents.NewParam("--model"),
}
} else {
agent.config = &agents.InferenceConfig{Supported: false}
}
return agent
}
// stubProfileResolver returns a fixed profile (or error) for every lookup.
type stubProfileResolver struct {
profile *AgentProfileInfo
err error
}
func (r *stubProfileResolver) ResolveProfile(context.Context, string) (*AgentProfileInfo, error) {
return r.profile, r.err
}
// inferenceHarness wires a Manager whose execution for "session-1" points at an
// in-process agentctl serving /api/v1/inference/prompt.
type inferenceHarness struct {
manager *Manager
mu sync.Mutex
requests []utility.PromptRequest
}
// recorded returns a copy of the prompt requests the fake agentctl saw. The
// handler appends from a server goroutine, so every read goes through the lock.
func (h *inferenceHarness) recorded() []utility.PromptRequest {
h.mu.Lock()
defer h.mu.Unlock()
return append([]utility.PromptRequest(nil), h.requests...)
}
func newInferenceHarness(t *testing.T, resolver ProfileResolver, registered ...agents.Agent) *inferenceHarness {
t.Helper()
h := &inferenceHarness{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/inference/prompt" {
w.WriteHeader(http.StatusNotFound)
return
}
var req utility.PromptRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
h.mu.Lock()
h.requests = append(h.requests, req)
h.mu.Unlock()
_ = json.NewEncoder(w).Encode(utility.PromptResponse{
Success: true,
Response: "answer for " + req.Prompt,
Model: req.Model,
})
}))
t.Cleanup(server.Close)
log := newTestLogger()
reg := newTestRegistry()
for _, agent := range registered {
if err := reg.Register(agent); err != nil {
t.Fatalf("register agent %s: %v", agent.ID(), err)
}
}
if resolver == nil {
resolver = &MockProfileResolver{}
}
manager := NewManager(reg, &MockEventBus{}, nil, &MockCredentialsManager{}, resolver,
nil, ExecutorFallbackWarn, "", log)
cleanupManagerStopCh(t, manager)
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
port, _ := strconv.Atoi(parsed.Port())
if err := manager.executionStore.Add(&AgentExecution{
ID: "execution-1",
SessionID: "session-1",
WorkspacePath: "/workspace/task-1",
agentctl: agentctlclient.NewClient(parsed.Hostname(), port, log),
}); err != nil {
t.Fatalf("seed execution: %v", err)
}
// An execution with no agentctl client, to cover the "client unavailable" path.
if err := manager.executionStore.Add(&AgentExecution{
ID: "execution-2",
SessionID: "session-no-client",
}); err != nil {
t.Fatalf("seed clientless execution: %v", err)
}
h.manager = manager
return h
}
func TestManagerExecuteInferencePromptValidation(t *testing.T) {
harness := newInferenceHarness(t, nil,
newInferenceAgent("inference-ok", true, true),
newInferenceAgent("inference-off", false, true),
)
t.Run("session id is required", func(t *testing.T) {
_, err := harness.manager.ExecuteInferencePrompt(t.Context(), "", "inference-ok", "", "hi")
if err == nil || !strings.Contains(err.Error(), "session_id is required") {
t.Fatalf("error = %v", err)
}
})
t.Run("agent id is required and is a client error sentinel", func(t *testing.T) {
_, err := harness.manager.ExecuteInferencePrompt(t.Context(), "session-1", "", "", "hi")
if !errors.Is(err, ErrInferenceAgentIDRequired) {
t.Fatalf("error = %v, want ErrInferenceAgentIDRequired", err)
}
})
t.Run("unknown agent", func(t *testing.T) {
_, err := harness.manager.ExecuteInferencePrompt(t.Context(), "session-1", "nope", "", "hi")
if err == nil || !strings.Contains(err.Error(), `agent "nope" does not support inference`) {
t.Fatalf("error = %v", err)
}
})
t.Run("agent declaring unsupported inference", func(t *testing.T) {
// The registry itself filters unsupported agents, so this surfaces as
// the same "does not support inference" rejection.
_, err := harness.manager.ExecuteInferencePrompt(t.Context(), "session-1", "inference-off", "", "hi")
if err == nil || !strings.Contains(err.Error(), "does not support inference") {
t.Fatalf("error = %v", err)
}
})
t.Run("no agentctl client for the session", func(t *testing.T) {
_, err := harness.manager.ExecuteInferencePrompt(t.Context(), "session-no-client", "inference-ok", "", "hi")
if err == nil || !strings.Contains(err.Error(), "agentctl client not available") {
t.Fatalf("error = %v", err)
}
})
}
func TestManagerExecuteInferencePromptSendsTheAgentInferenceConfig(t *testing.T) {
agent := newInferenceAgent("inference-ok", true, true)
agent.stripEnv = []string{"NODE_OPTIONS"}
harness := newInferenceHarness(t, nil, agent)
resp, err := harness.manager.ExecuteInferencePrompt(t.Context(),
"session-1", "inference-ok", "claude-haiku-4-5", "summarise this")
if err != nil {
t.Fatalf("ExecuteInferencePrompt: %v", err)
}
if !resp.Success || resp.Response != "answer for summarise this" {
t.Fatalf("response = %+v", resp)
}
recorded := harness.recorded()
if len(recorded) != 1 {
t.Fatalf("agentctl calls = %d, want 1", len(recorded))
}
got := recorded[0]
if got.Prompt != "summarise this" || got.AgentID != "inference-ok" || got.Model != "claude-haiku-4-5" {
t.Fatalf("request = %+v", got)
}
if got.InferenceConfig == nil {
t.Fatal("request must carry the agent's inference config")
}
wantCommand := []string{"npx", "-y", "@acp/inference-ok"}
if !equalStrings(got.InferenceConfig.Command, wantCommand) {
t.Fatalf("Command = %v, want %v", got.InferenceConfig.Command, wantCommand)
}
if !equalStrings(got.InferenceConfig.ModelFlag, []string{"--model"}) {
t.Fatalf("ModelFlag = %v", got.InferenceConfig.ModelFlag)
}
if got.InferenceConfig.WorkDir != "/workspace/task-1" {
t.Fatalf("WorkDir = %q, want the execution's workspace", got.InferenceConfig.WorkDir)
}
if !equalStrings(got.InferenceConfig.StripEnv, []string{"NODE_OPTIONS"}) {
t.Fatalf("StripEnv = %v, want the agent runtime's list", got.InferenceConfig.StripEnv)
}
}
func TestManagerExecuteInferenceProfilePrompt(t *testing.T) {
t.Run("no resolver configured", func(t *testing.T) {
manager := NewManager(newTestRegistry(), &MockEventBus{}, nil, &MockCredentialsManager{},
nil, nil, ExecutorFallbackWarn, "", newTestLogger())
cleanupManagerStopCh(t, manager)
_, err := manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hi")
if err == nil || !strings.Contains(err.Error(), "agent profile resolver is not configured") {
t.Fatalf("error = %v", err)
}
})
t.Run("resolver failure bubbles up", func(t *testing.T) {
wantErr := errors.New("profile lookup failed")
harness := newInferenceHarness(t, &stubProfileResolver{err: wantErr})
_, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hi")
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
})
t.Run("profile without an agent is not executable", func(t *testing.T) {
harness := newInferenceHarness(t, &stubProfileResolver{profile: &AgentProfileInfo{ProfileID: "p"}})
_, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hi")
if err == nil || !strings.Contains(err.Error(), "is not executable") {
t.Fatalf("error = %v", err)
}
harness = newInferenceHarness(t, &stubProfileResolver{profile: nil})
_, err = harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hi")
if err == nil || !strings.Contains(err.Error(), "is not executable") {
t.Fatalf("nil profile error = %v", err)
}
})
t.Run("session id is required", func(t *testing.T) {
harness := newInferenceHarness(t,
&stubProfileResolver{profile: &AgentProfileInfo{AgentID: "inference-ok"}},
newInferenceAgent("inference-ok", true, true))
_, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "", "profile-1", "hi")
if err == nil || !strings.Contains(err.Error(), "session_id is required") {
t.Fatalf("error = %v", err)
}
})
t.Run("profile agent must support inference", func(t *testing.T) {
harness := newInferenceHarness(t,
&stubProfileResolver{profile: &AgentProfileInfo{AgentID: "not-registered"}})
_, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hi")
if err == nil || !strings.Contains(err.Error(), "does not support inference") {
t.Fatalf("error = %v", err)
}
})
t.Run("invalid command prefix is rejected", func(t *testing.T) {
harness := newInferenceHarness(t, &stubProfileResolver{profile: &AgentProfileInfo{
AgentID: "inference-ok",
CommandPrefix: "unterminated 'quote",
}}, newInferenceAgent("inference-ok", true, true))
_, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hi")
if err == nil {
t.Fatal("expected the malformed command prefix to be rejected")
}
})
t.Run("applies model, mode, flags, prefix, env and permission policy", func(t *testing.T) {
harness := newInferenceHarness(t, &stubProfileResolver{profile: &AgentProfileInfo{
ProfileID: "profile-1",
AgentID: "inference-ok",
Model: "claude-haiku-4-5",
Mode: "plan",
AutoApprove: true,
CommandPrefix: "greywall --",
CLIFlags: []settingsmodels.CLIFlag{
{Flag: "--verbose", Enabled: true},
},
EnvVars: []settingsmodels.ProfileEnvVar{
{Key: "PROFILE_VAR", Value: "profile-value"},
{Key: "SECRET_VAR", Value: "ignored", SecretID: "secret-1"},
{Key: "", Value: "dropped"},
},
}}, newInferenceAgent("inference-ok", true, true))
resp, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hello")
if err != nil {
t.Fatalf("ExecuteInferenceProfilePrompt: %v", err)
}
if resp.Response != "answer for hello" {
t.Fatalf("response = %+v", resp)
}
recorded := harness.recorded()
if len(recorded) != 1 {
t.Fatalf("agentctl calls = %d", len(recorded))
}
got := recorded[0]
if got.AgentID != "inference-ok" || got.Model != "claude-haiku-4-5" || got.Mode != "plan" {
t.Fatalf("request = %+v", got)
}
if got.AutoApprovePermissions == nil || !*got.AutoApprovePermissions {
t.Fatalf("AutoApprovePermissions = %v, want the profile value", got.AutoApprovePermissions)
}
if got.InferenceConfig == nil {
t.Fatal("missing inference config")
}
if !equalStrings(got.InferenceConfig.CommandPrefix, []string{"greywall", "--"}) {
t.Fatalf("CommandPrefix = %v", got.InferenceConfig.CommandPrefix)
}
if !equalStrings(got.InferenceConfig.CLIFlags, []string{"--verbose"}) {
t.Fatalf("CLIFlags = %v", got.InferenceConfig.CLIFlags)
}
if got.InferenceConfig.Env["PROFILE_VAR"] != "profile-value" {
t.Fatalf("Env = %+v, want the plain profile var", got.InferenceConfig.Env)
}
if _, leaked := got.InferenceConfig.Env["SECRET_VAR"]; leaked {
t.Fatalf("secret-backed env vars must not be inlined: %+v", got.InferenceConfig.Env)
}
if _, present := got.InferenceConfig.Env[""]; present {
t.Fatalf("blank env keys must be dropped: %+v", got.InferenceConfig.Env)
}
if got.InferenceConfig.WorkDir != "/workspace/task-1" {
t.Fatalf("WorkDir = %q", got.InferenceConfig.WorkDir)
}
})
}
// The agents table's primary key is a generated UUID while the registry is
// keyed by the agent slug, so AgentID and AgentName never carry the same value
// in production. Every fixture above uses one string for both, which hid the
// registry lookup reading AgentID.
func TestManagerExecuteInferenceProfilePromptUsesTheRegistryAgentName(t *testing.T) {
const dbAgentUUID = "6a2b0f5c-6b9b-4f0e-8f3b-1f2c3d4e5f60"
harness := newInferenceHarness(t, &stubProfileResolver{profile: &AgentProfileInfo{
ProfileID: "profile-1",
AgentID: dbAgentUUID,
AgentName: "inference-ok",
Model: "claude-haiku-4-5",
}}, newInferenceAgent("inference-ok", true, true))
resp, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hello")
if err != nil {
t.Fatalf("ExecuteInferenceProfilePrompt: %v", err)
}
if resp.Response != "answer for hello" {
t.Fatalf("response = %+v", resp)
}
recorded := harness.recorded()
if len(recorded) != 1 {
t.Fatalf("agentctl calls = %d", len(recorded))
}
// agentctl keys its ACP compatibility and session handling off this field,
// so the database UUID must never reach the wire.
if recorded[0].AgentID != "inference-ok" {
t.Fatalf("outgoing AgentID = %q, want the registry agent name", recorded[0].AgentID)
}
}
func TestManagerExecuteInferenceProfilePromptFallsBackToAgentIDWithoutAName(t *testing.T) {
harness := newInferenceHarness(t, &stubProfileResolver{profile: &AgentProfileInfo{
ProfileID: "profile-1",
AgentID: "inference-ok",
}}, newInferenceAgent("inference-ok", true, true))
if _, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hello"); err != nil {
t.Fatalf("ExecuteInferenceProfilePrompt: %v", err)
}
recorded := harness.recorded()
if len(recorded) != 1 || recorded[0].AgentID != "inference-ok" {
t.Fatalf("requests = %+v, want the AgentID fallback to reach agentctl", recorded)
}
}
func TestManagerExecuteInferenceProfilePromptReportsTheAgentNameOnLookupFailure(t *testing.T) {
harness := newInferenceHarness(t, &stubProfileResolver{profile: &AgentProfileInfo{
ProfileID: "profile-1",
AgentID: "6a2b0f5c-6b9b-4f0e-8f3b-1f2c3d4e5f60",
AgentName: "not-registered",
}})
_, err := harness.manager.ExecuteInferenceProfilePrompt(t.Context(), "session-1", "profile-1", "hello")
// The UUID is meaningless to anyone reading the error; name the agent.
if err == nil || !strings.Contains(err.Error(), `agent "not-registered" does not support inference`) {
t.Fatalf("error = %v", err)
}
}
func TestManagerListInferenceAgentsOnlyReturnsInstalledOnes(t *testing.T) {
installed := newInferenceAgent("inference-installed", true, true)
missing := newInferenceAgent("inference-missing", true, false)
harness := newInferenceHarness(t, nil, installed, missing)
got := harness.manager.ListInferenceAgents()
var ids []string
for _, info := range got {
ids = append(ids, info.ID)
}
if !sshContainsStep(ids, "inference-installed") {
t.Fatalf("agents = %v, want the installed inference agent", ids)
}
if sshContainsStep(ids, "inference-missing") {
t.Fatalf("agents = %v, must exclude uninstalled agents", ids)
}
for _, info := range got {
if info.ID == "inference-installed" {
if info.Name != "inference-installed-name" || info.DisplayName != "inference-installed-display" {
t.Fatalf("agent info = %+v, want the agent's own identity fields", info)
}
}
}
// The context-taking variant is what the HTTP layer uses; it must agree.
withCtx := harness.manager.ListInferenceAgentsWithContext(t.Context())
if len(withCtx) != len(got) {
t.Fatalf("ListInferenceAgentsWithContext returned %d agents, want %d", len(withCtx), len(got))
}
}