Files
kandev/apps/backend/internal/agent/runtime/runtime_contract_test.go
T

511 lines
16 KiB
Go

package runtime_test
import (
"context"
"errors"
"strings"
"testing"
"time"
agentruntime "github.com/kandev/kandev/internal/agent/runtime"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// fakeBackend is the runtime contract test stand-in for *lifecycle.Manager.
// It records every call so the test can assert the facade translates
// Runtime verbs into Backend verbs faithfully.
type fakeBackend struct {
executions map[string]*lifecycle.AgentExecution
launchReq *lifecycle.LaunchRequest
launchExec *lifecycle.AgentExecution
launchErr error
startCalls []string
startErr error
promptCalls []promptCall
promptResult *lifecycle.PromptResult
promptErr error
stopCalls []stopCall
stopErr error
mcpCalls []mcpCall
mcpErr error
}
type promptCall struct {
executionID string
prompt string
dispatchOnly bool
}
type stopCall struct {
executionID string
reason string
force bool
}
type mcpCall struct {
executionID string
mode string
}
type fakeOwnerAdmission struct {
calls int
err error
}
func (f *fakeOwnerAdmission) AdmitExecution(context.Context, lifecycle.ExecutionOwner) error {
f.calls++
return f.err
}
func (f *fakeBackend) Launch(_ context.Context, req *lifecycle.LaunchRequest) (*lifecycle.AgentExecution, error) {
f.launchReq = req
if f.launchErr != nil {
return nil, f.launchErr
}
if f.launchExec != nil {
f.executions[f.launchExec.ID] = f.launchExec
}
return f.launchExec, nil
}
// StartAgentProcess is intentionally present in the test backend even before
// Runtime.Start is wired. The failing contract test below proves the facade
// does not expose the required start-and-prompt seam yet.
func (f *fakeBackend) StartAgentProcess(_ context.Context, executionID string) error {
f.startCalls = append(f.startCalls, executionID)
if f.launchReq != nil && f.launchReq.TaskDescription != "" {
f.promptCalls = append(f.promptCalls, promptCall{
executionID: executionID,
prompt: f.launchReq.TaskDescription,
})
}
return f.startErr
}
func (f *fakeBackend) PromptAgent(_ context.Context, executionID, prompt string, _ []v1.MessageAttachment, dispatchOnly bool) (*lifecycle.PromptResult, error) {
f.promptCalls = append(f.promptCalls, promptCall{executionID: executionID, prompt: prompt, dispatchOnly: dispatchOnly})
if f.promptErr != nil {
return nil, f.promptErr
}
return f.promptResult, nil
}
func (f *fakeBackend) StopAgentWithReason(_ context.Context, executionID, reason string, force bool) error {
f.stopCalls = append(f.stopCalls, stopCall{executionID: executionID, reason: reason, force: force})
return f.stopErr
}
func (f *fakeBackend) GetExecution(executionID string) (*lifecycle.AgentExecution, bool) {
exec, ok := f.executions[executionID]
return exec, ok
}
func (f *fakeBackend) SetMcpMode(_ context.Context, executionID, mode string) error {
f.mcpCalls = append(f.mcpCalls, mcpCall{executionID: executionID, mode: mode})
return f.mcpErr
}
func newFakeBackend() *fakeBackend {
return &fakeBackend{executions: map[string]*lifecycle.AgentExecution{}}
}
func TestRuntime_Launch_TranslatesSpecToLaunchRequest(t *testing.T) {
t.Parallel()
now := time.Now().UTC()
backend := newFakeBackend()
backend.launchExec = &lifecycle.AgentExecution{
ID: "exec-1",
SessionID: "sess-1",
StartedAt: now,
}
rt := agentruntime.New(backend)
ref, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{
AgentProfileID: "claude-acp-default",
ExecutorID: "local_pc",
Workspace: agentruntime.WorkspaceRef{
Path: "/work/foo",
RepositoryID: "repo-1",
IsEphemeral: true,
},
Prompt: "do the thing",
PriorACPSession: "acp-prior",
McpMode: "config",
Metadata: map[string]any{
"source": "engine",
},
})
if err != nil {
t.Fatalf("Launch returned error: %v", err)
}
if ref.ID != "exec-1" || ref.SessionID != "sess-1" {
t.Fatalf("unexpected ExecutionRef: %+v", ref)
}
if !ref.StartedAt.Equal(now) {
t.Fatalf("StartedAt mismatch: got %v want %v", ref.StartedAt, now)
}
// AgentctlURL is empty when the execution has no agentctl client wired
// (which is the case for the fakeBackend). The field must be present on
// the ref — callers that don't read it are unaffected.
if ref.AgentctlURL != "" {
t.Errorf("expected empty AgentctlURL for execution with no client, got %q", ref.AgentctlURL)
}
if backend.launchReq == nil {
t.Fatal("backend.Launch was not called")
}
got := backend.launchReq
if got.AgentProfileID != "claude-acp-default" {
t.Errorf("AgentProfileID = %q", got.AgentProfileID)
}
if got.ExecutorType != "local_pc" {
t.Errorf("ExecutorType = %q", got.ExecutorType)
}
if got.WorkspacePath != "/work/foo" {
t.Errorf("WorkspacePath = %q", got.WorkspacePath)
}
if got.RepositoryID != "repo-1" {
t.Errorf("RepositoryID = %q", got.RepositoryID)
}
if !got.IsEphemeral {
t.Errorf("IsEphemeral = false, want true")
}
if got.TaskDescription != "do the thing" {
t.Errorf("TaskDescription = %q", got.TaskDescription)
}
if got.ACPSessionID != "acp-prior" {
t.Errorf("ACPSessionID = %q", got.ACPSessionID)
}
if got.McpMode != "config" {
t.Errorf("McpMode = %q", got.McpMode)
}
if got.Metadata["source"] != "engine" {
t.Errorf("Metadata[source] = %v", got.Metadata["source"])
}
}
func TestRuntime_StartDispatchesInitialPromptOnce(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.launchExec = &lifecycle.AgentExecution{ID: "exec-start", SessionID: "session-start"}
rt := agentruntime.New(backend)
starter, ok := rt.(interface {
Start(context.Context, agentruntime.LaunchSpec) (agentruntime.ExecutionRef, error)
})
if !ok {
t.Fatal("runtime does not expose Start")
}
if _, err := starter.Start(context.Background(), agentruntime.LaunchSpec{Prompt: "initial"}); err != nil {
t.Fatalf("Start returned error: %v", err)
}
if len(backend.startCalls) != 1 || backend.startCalls[0] != "exec-start" {
t.Fatalf("start calls = %#v, want one call for exec-start", backend.startCalls)
}
if len(backend.promptCalls) != 1 || backend.promptCalls[0].prompt != "initial" {
t.Fatalf("prompt calls = %#v, want one initial prompt", backend.promptCalls)
}
}
func TestRuntime_StartRollsBackRegistrationFailure(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.launchExec = &lifecycle.AgentExecution{ID: "exec-rollback", SessionID: "session-rollback"}
backend.startErr = errors.New("startup failed")
rt := agentruntime.New(backend)
starter, ok := rt.(interface {
Start(context.Context, agentruntime.LaunchSpec) (agentruntime.ExecutionRef, error)
})
if !ok {
t.Fatal("runtime does not expose Start")
}
if _, err := starter.Start(context.Background(), agentruntime.LaunchSpec{}); err == nil {
t.Fatal("Start returned nil error after startup failure")
}
if len(backend.stopCalls) != 1 || backend.stopCalls[0].executionID != "exec-rollback" {
t.Fatalf("stop calls = %#v, want one rollback for exec-rollback", backend.stopCalls)
}
}
func TestRuntime_RunOwnerAdmissionRejectsStaleAttempt(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.launchExec = &lifecycle.AgentExecution{ID: "exec-owner"}
admission := &fakeOwnerAdmission{err: errors.New("run attempt is stale")}
rt := agentruntime.New(backend)
_, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{
Owner: lifecycle.ExecutionOwner{
Kind: lifecycle.ExecutionOwnerRun,
WorkspaceID: "workspace-owner",
RunID: "run-owner",
RunSessionID: "session-owner",
Attempt: 3,
},
OwnerAdmission: admission,
})
if err == nil || !strings.Contains(err.Error(), "stale") {
t.Fatalf("Launch error = %v, want stale-owner rejection", err)
}
if admission.calls != 1 {
t.Fatalf("admission calls = %d, want 1", admission.calls)
}
if backend.launchReq != nil {
t.Fatal("backend launch ran after owner admission rejected the attempt")
}
}
func TestRuntime_TaskOwnerDoesNotRequireRunAdmission(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.launchExec = &lifecycle.AgentExecution{ID: "exec-task-owner"}
rt := agentruntime.New(backend)
if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{
Owner: lifecycle.ExecutionOwner{
Kind: lifecycle.ExecutionOwnerTask,
TaskID: "task-owner",
SessionID: "session-owner",
},
}); err != nil {
t.Fatalf("task-owned Launch returned error: %v", err)
}
}
func TestRuntime_Launch_PassesThroughLaunchRequestMetadata(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.launchExec = &lifecycle.AgentExecution{ID: "exec-2"}
base := &lifecycle.LaunchRequest{
TaskID: "task-A",
SessionID: "sess-A",
UseWorktree: true,
Repositories: []lifecycle.RepoLaunchSpec{
{RepositoryID: "r1", BaseBranch: "main"},
},
}
rt := agentruntime.New(backend)
if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{
AgentProfileID: "p1",
Prompt: "hello",
Metadata: map[string]any{
"launch_request": base,
},
}); err != nil {
t.Fatalf("Launch returned error: %v", err)
}
got := backend.launchReq
if got == nil {
t.Fatal("backend.Launch not called")
}
if got.TaskID != "task-A" || got.SessionID != "sess-A" {
t.Errorf("base fields lost: TaskID=%q SessionID=%q", got.TaskID, got.SessionID)
}
if !got.UseWorktree {
t.Errorf("UseWorktree lost from base launch_request")
}
if len(got.Repositories) != 1 || got.Repositories[0].RepositoryID != "r1" {
t.Errorf("Repositories lost from base launch_request: %+v", got.Repositories)
}
// Spec fields override base.
if got.AgentProfileID != "p1" {
t.Errorf("AgentProfileID overlay lost: %q", got.AgentProfileID)
}
if got.TaskDescription != "hello" {
t.Errorf("TaskDescription overlay lost: %q", got.TaskDescription)
}
// launch_request key itself does not leak into LaunchRequest.Metadata.
if _, found := got.Metadata["launch_request"]; found {
t.Errorf("launch_request key leaked into LaunchRequest.Metadata")
}
}
func TestRuntime_Launch_PropagatesError(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.launchErr = errors.New("boom")
rt := agentruntime.New(backend)
if _, err := rt.Launch(context.Background(), agentruntime.LaunchSpec{}); err == nil {
t.Fatal("expected error")
}
}
func TestRuntime_Resume_DelegatesToPromptAgent(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
rt := agentruntime.New(backend)
if err := rt.Resume(context.Background(), "exec-1", "next prompt"); err != nil {
t.Fatalf("Resume returned: %v", err)
}
if len(backend.promptCalls) != 1 {
t.Fatalf("expected 1 prompt call, got %d", len(backend.promptCalls))
}
got := backend.promptCalls[0]
if got.executionID != "exec-1" || got.prompt != "next prompt" || got.dispatchOnly != false {
t.Errorf("unexpected prompt call: %+v", got)
}
}
func TestRuntime_Resume_RequiresExecutionID(t *testing.T) {
t.Parallel()
rt := agentruntime.New(newFakeBackend())
if err := rt.Resume(context.Background(), "", "prompt"); err == nil {
t.Fatal("expected error for empty executionID")
}
}
func TestRuntime_Resume_PropagatesError(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.promptErr = errors.New("nope")
rt := agentruntime.New(backend)
if err := rt.Resume(context.Background(), "exec-1", "p"); err == nil {
t.Fatal("expected error")
}
}
func TestRuntime_Stop_DelegatesToBackend(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
rt := agentruntime.New(backend)
if err := rt.Stop(context.Background(), "exec-1", "user_cancelled"); err != nil {
t.Fatalf("Stop returned: %v", err)
}
if len(backend.stopCalls) != 1 {
t.Fatalf("expected 1 stop call, got %d", len(backend.stopCalls))
}
got := backend.stopCalls[0]
if got.executionID != "exec-1" || got.reason != "user_cancelled" || got.force {
t.Errorf("unexpected stop call: %+v", got)
}
}
func TestRuntime_Stop_ClassifiesMissingExecution(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
backend.stopErr = lifecycle.ErrExecutionNotFound
rt := agentruntime.New(backend)
err := rt.Stop(context.Background(), "missing", "cleanup")
if !errors.Is(err, agentruntime.ErrNotFound) {
t.Fatalf("Stop error = %v, want runtime.ErrNotFound", err)
}
if !errors.Is(err, lifecycle.ErrExecutionNotFound) {
t.Fatalf("Stop error = %v, want lifecycle.ErrExecutionNotFound", err)
}
}
func TestRuntime_Stop_RequiresExecutionID(t *testing.T) {
t.Parallel()
rt := agentruntime.New(newFakeBackend())
if err := rt.Stop(context.Background(), "", "reason"); err == nil {
t.Fatal("expected error for empty executionID")
}
}
func TestRuntime_GetExecution_ReturnsSnapshot(t *testing.T) {
t.Parallel()
finishedAt := time.Now().UTC()
exit := 0
backend := newFakeBackend()
backend.executions["exec-1"] = &lifecycle.AgentExecution{
ID: "exec-1",
SessionID: "sess-1",
TaskID: "task-1",
AgentProfileID: "p1",
WorkspacePath: "/w",
Status: v1.AgentStatus("ready"),
StartedAt: finishedAt.Add(-time.Minute),
FinishedAt: &finishedAt,
ExitCode: &exit,
ACPSessionID: "acp-1",
}
backend.executions["exec-1"].SetMetadataForTesting(map[string]interface{}{"k": "v"})
rt := agentruntime.New(backend)
got, err := rt.GetExecution(context.Background(), "exec-1")
if err != nil {
t.Fatalf("GetExecution returned: %v", err)
}
if got.ID != "exec-1" || got.SessionID != "sess-1" || got.TaskID != "task-1" {
t.Errorf("snapshot identifiers wrong: %+v", got)
}
if got.AgentProfileID != "p1" || got.WorkspacePath != "/w" {
t.Errorf("snapshot fields wrong: %+v", got)
}
if got.Status != v1.AgentStatus("ready") {
t.Errorf("Status mismatch: %q", got.Status)
}
if got.FinishedAt == nil || !got.FinishedAt.Equal(finishedAt) {
t.Errorf("FinishedAt mismatch")
}
if got.ExitCode == nil || *got.ExitCode != 0 {
t.Errorf("ExitCode mismatch")
}
if got.ACPSessionID != "acp-1" {
t.Errorf("ACPSessionID mismatch: %q", got.ACPSessionID)
}
if got.Metadata["k"] != "v" {
t.Errorf("Metadata mismatch: %+v", got.Metadata)
}
// AgentctlURL is empty when the execution has no agentctl client wired.
if got.AgentctlURL != "" {
t.Errorf("expected empty AgentctlURL for execution with no client, got %q", got.AgentctlURL)
}
}
func TestRuntime_GetExecution_NotFound(t *testing.T) {
t.Parallel()
rt := agentruntime.New(newFakeBackend())
_, err := rt.GetExecution(context.Background(), "missing")
if !errors.Is(err, agentruntime.ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestRuntime_GetExecution_RequiresExecutionID(t *testing.T) {
t.Parallel()
rt := agentruntime.New(newFakeBackend())
if _, err := rt.GetExecution(context.Background(), ""); err == nil {
t.Fatal("expected error")
}
}
func TestRuntime_SetMcpMode_DelegatesToBackend(t *testing.T) {
t.Parallel()
backend := newFakeBackend()
rt := agentruntime.New(backend)
if err := rt.SetMcpMode(context.Background(), "exec-1", "config"); err != nil {
t.Fatalf("SetMcpMode returned: %v", err)
}
if len(backend.mcpCalls) != 1 {
t.Fatalf("expected 1 mcp call, got %d", len(backend.mcpCalls))
}
got := backend.mcpCalls[0]
if got.executionID != "exec-1" || got.mode != "config" {
t.Errorf("unexpected mcp call: %+v", got)
}
}
func TestRuntime_SetMcpMode_RequiresExecutionID(t *testing.T) {
t.Parallel()
rt := agentruntime.New(newFakeBackend())
if err := rt.SetMcpMode(context.Background(), "", "config"); err == nil {
t.Fatal("expected error")
}
}
func TestRuntime_SubscribeEvents_UnsupportedInPhase1(t *testing.T) {
t.Parallel()
// Phase 1 leaves SubscribeEvents best-effort: the lifecycle manager
// publishes events through the global event bus rather than
// per-execution channels, so the facade returns ErrUnsupported.
// Phase 2/3 may add a per-execution channel.
rt := agentruntime.New(newFakeBackend())
ch, err := rt.SubscribeEvents(context.Background(), "exec-1")
if !errors.Is(err, agentruntime.ErrUnsupported) {
t.Fatalf("expected ErrUnsupported, got %v", err)
}
if ch != nil {
t.Fatalf("expected nil channel, got %v", ch)
}
}