Files
kandev/apps/backend/internal/orchestrator/session_launch_test.go
T
2026-09-01 21:15:11 +00:00

643 lines
21 KiB
Go

package orchestrator
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type rejectingLaunchAttachmentClaimer struct {
wantErr error
taskID string
sessionID string
attachments []v1.MessageAttachment
}
func (c *rejectingLaunchAttachmentClaimer) ClaimMessageAttachments(
_ context.Context,
taskID, sessionID string,
attachments []v1.MessageAttachment,
) error {
c.taskID = taskID
c.sessionID = sessionID
c.attachments = attachments
return c.wantErr
}
func TestExecutionToLaunchResponseReportsAgentProfile(t *testing.T) {
response := executionToLaunchResponse("task-1", &executor.TaskExecution{
SessionID: "session-1",
AgentExecutionID: "execution-1",
AgentProfileID: "effective-profile",
SessionState: v1.TaskSessionStateRunning,
})
if got, want := response.AgentProfileID, "effective-profile"; got != want {
t.Fatalf("agent_profile_id = %v, want %v", got, want)
}
}
func TestExecutionToLaunchResponseHandlesNilExecution(t *testing.T) {
// A guarded automatic launch can return a successful no-op without an
// execution. The response helper is defensive for callers that use it
// directly or through another launch path.
response := executionToLaunchResponse("task-1", nil)
if response == nil {
t.Fatal("expected a response")
return
}
if !response.Success {
t.Fatal("expected a successful no-op response")
}
if response.TaskID != "task-1" {
t.Fatalf("task_id = %q, want task-1", response.TaskID)
}
if response.SessionID != "" {
t.Fatalf("session_id = %q, want empty", response.SessionID)
}
}
func TestResolveIntent(t *testing.T) {
tests := []struct {
name string
req LaunchSessionRequest
want SessionIntent
}{
// Explicit intents take priority
{
name: "explicit start intent",
req: LaunchSessionRequest{TaskID: "t1", Intent: IntentStart},
want: IntentStart,
},
{
name: "explicit resume intent",
req: LaunchSessionRequest{TaskID: "t1", Intent: IntentResume, SessionID: "s1"},
want: IntentResume,
},
{
name: "explicit prepare intent",
req: LaunchSessionRequest{TaskID: "t1", Intent: IntentPrepare},
want: IntentPrepare,
},
{
name: "explicit start_created intent",
req: LaunchSessionRequest{TaskID: "t1", Intent: IntentStartCreated, SessionID: "s1"},
want: IntentStartCreated,
},
{
name: "explicit workflow_step intent",
req: LaunchSessionRequest{TaskID: "t1", Intent: IntentWorkflowStep, SessionID: "s1", WorkflowStepID: "ws1"},
want: IntentWorkflowStep,
},
// Inferred intents (no explicit intent set)
{
name: "workflow_step inferred from session_id + workflow_step_id",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1", WorkflowStepID: "ws1"},
want: IntentWorkflowStep,
},
{
name: "resume inferred from session_id only",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1"},
want: IntentResume,
},
{
name: "resume inferred from session_id with no prompt and no agent_profile",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1"},
want: IntentResume,
},
{
name: "start_created inferred from session_id + prompt",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1", Prompt: "hello"},
want: IntentStartCreated,
},
{
name: "start_created inferred from session_id + agent_profile_id",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1", AgentProfileID: "ap1"},
want: IntentStartCreated,
},
{
name: "start_created inferred from session_id + prompt + agent_profile_id",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1", Prompt: "hello", AgentProfileID: "ap1"},
want: IntentStartCreated,
},
{
name: "prepare inferred from launch_workspace without prompt",
req: LaunchSessionRequest{TaskID: "t1", LaunchWorkspace: true},
want: IntentPrepare,
},
{
name: "start inferred from minimal request",
req: LaunchSessionRequest{TaskID: "t1"},
want: IntentStart,
},
{
name: "start inferred when prompt provided without session_id",
req: LaunchSessionRequest{TaskID: "t1", Prompt: "do something"},
want: IntentStart,
},
{
name: "start inferred when launch_workspace + prompt (not prepare)",
req: LaunchSessionRequest{TaskID: "t1", LaunchWorkspace: true, Prompt: "do something"},
want: IntentStart,
},
// Edge cases
{
name: "resume wins over start_created when session_id set, no prompt, no agent_profile",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1", ExecutorID: "e1"},
want: IntentResume,
},
{
name: "workflow_step wins over resume when both session_id and workflow_step_id set",
req: LaunchSessionRequest{TaskID: "t1", SessionID: "s1", WorkflowStepID: "ws1"},
want: IntentWorkflowStep,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ResolveIntent(&tt.req)
if got != tt.want {
t.Errorf("ResolveIntent() = %q, want %q", got, tt.want)
}
})
}
}
func TestLaunchSession_RejectsAttachmentClaimBeforeStart(t *testing.T) {
wantErr := errors.New("attachment is not available")
claimer := &rejectingLaunchAttachmentClaimer{wantErr: wantErr}
service := &Service{}
service.SetLaunchAttachmentClaimer(claimer)
attachment := v1.MessageAttachment{AttachmentID: "attachment-1", Name: "notes.txt"}
_, err := service.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task-1",
SessionID: "session-1",
Intent: IntentStartCreated,
Prompt: "read the attachment",
Attachments: []v1.MessageAttachment{attachment},
})
if !errors.Is(err, wantErr) {
t.Fatalf("LaunchSession error = %v, want %v", err, wantErr)
}
if claimer.taskID != "task-1" || claimer.sessionID != "session-1" {
t.Fatalf("claim scope = (%q, %q), want (task-1, session-1)", claimer.taskID, claimer.sessionID)
}
if len(claimer.attachments) != 1 || claimer.attachments[0].AttachmentID != attachment.AttachmentID {
t.Fatalf("claimed attachments = %+v", claimer.attachments)
}
}
func TestLaunchSession_RejectsMismatchedTaskSessionBeforeAttachmentClaim(t *testing.T) {
claimer := &rejectingLaunchAttachmentClaimer{wantErr: errors.New("claimer must not run")}
service := &Service{repo: &pairStubRepo{sessionTaskID: "task-other"}}
service.SetLaunchAttachmentClaimer(claimer)
_, err := service.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task-1",
SessionID: "session-1",
Intent: IntentStartCreated,
Prompt: "read the attachment",
Attachments: []v1.MessageAttachment{{AttachmentID: "attachment-1"}},
})
if err == nil || !strings.Contains(err.Error(), "does not belong to task") {
t.Fatalf("LaunchSession error = %v, want task/session mismatch", err)
}
if claimer.taskID != "" || len(claimer.attachments) != 0 {
t.Fatalf("attachment claimer ran for mismatched pair: %+v", claimer)
}
}
func TestNormalizeRecoverSessionError(t *testing.T) {
t.Run("maps profile not found errors to actionable profile guidance", func(t *testing.T) {
in := errors.New("failed to resolve agent profile: profile not found: sql: no rows in result set")
err := normalizeRecoverSessionError(in)
if err == nil {
t.Fatal("expected mapped error")
}
want := "the agent profile used by this session was deleted; start a new session and choose an available agent profile: " + in.Error()
if got := err.Error(); got != want {
t.Fatalf("unexpected error: %q", got)
}
})
t.Run("maps agent profile not found errors to actionable profile guidance", func(t *testing.T) {
in := errors.New("agent profile not found")
err := normalizeRecoverSessionError(in)
if err == nil {
t.Fatal("expected mapped error")
}
want := "the agent profile used by this session was deleted; start a new session and choose an available agent profile: " + in.Error()
if got := err.Error(); got != want {
t.Fatalf("unexpected error: %q", got)
}
})
t.Run("does not map generic sql no rows errors", func(t *testing.T) {
in := errors.New("sql: no rows in result set")
err := normalizeRecoverSessionError(in)
if err == nil {
t.Fatal("expected passthrough error")
}
if err.Error() != in.Error() {
t.Fatalf("expected passthrough error %q, got %q", in.Error(), err.Error())
}
})
t.Run("does not map executor profile not found errors", func(t *testing.T) {
in := errors.New("executor profile not found")
err := normalizeRecoverSessionError(in)
if err == nil {
t.Fatal("expected passthrough error")
}
if err.Error() != in.Error() {
t.Fatalf("expected passthrough error %q, got %q", in.Error(), err.Error())
}
})
t.Run("passes through unrelated errors", func(t *testing.T) {
in := errors.New("network timeout")
err := normalizeRecoverSessionError(in)
if err == nil {
t.Fatal("expected passthrough error")
}
if err.Error() != in.Error() {
t.Fatalf("expected passthrough error %q, got %q", in.Error(), err.Error())
}
})
}
// --- launchRestoreWorkspace ---
func TestLaunchRestoreWorkspace_MissingSessionID(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
_, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentRestoreWorkspace,
})
if err == nil {
t.Fatal("expected error when session_id is empty")
}
}
func TestLaunchRestoreWorkspace_SessionNotFound(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
_, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentRestoreWorkspace,
SessionID: "nonexistent",
})
if err == nil {
t.Fatal("expected error when session does not exist")
}
}
func TestLaunchRestoreWorkspace_WrongTask(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
seedTaskAndSession(t, repo, "task-other", "session1", models.TaskSessionStateCompleted)
_, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task-wrong",
Intent: IntentRestoreWorkspace,
SessionID: "session1",
})
if err == nil {
t.Fatal("expected error when session does not belong to task")
}
}
func TestLaunchRestoreWorkspace_Success(t *testing.T) {
repo := setupTestRepo(t)
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted)
resp, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentRestoreWorkspace,
SessionID: "session1",
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if !resp.Success {
t.Fatal("expected success=true")
}
if resp.SessionID != "session1" {
t.Errorf("expected session_id 'session1', got %q", resp.SessionID)
}
if resp.State != string(models.TaskSessionStateCompleted) {
t.Errorf("expected state %q, got %q", models.TaskSessionStateCompleted, resp.State)
}
}
// --- launchPrepare passthrough upgrade ---
func TestIsPassthroughProfile(t *testing.T) {
repo := setupTestRepo(t)
passthroughMgr := &mockAgentManager{isPassthrough: true}
regularMgr := &mockAgentManager{isPassthrough: false}
t.Run("passthrough profile detected", func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), passthroughMgr)
if !svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=true for passthrough profile")
}
})
t.Run("non-passthrough profile not detected", func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), regularMgr)
if svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=false for non-passthrough profile")
}
})
t.Run("empty profile id returns false", func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), passthroughMgr)
if svc.isPassthroughProfile(context.Background(), "") {
t.Error("expected isPassthroughProfile=false for empty profile id")
}
})
t.Run("nil agent manager returns false", func(t *testing.T) {
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.agentManager = nil
if svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=false when agent manager is nil")
}
})
t.Run("resolver error returns false", func(t *testing.T) {
errorMgr := &mockAgentManager{resolveProfileErr: errors.New("lookup failed")}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), errorMgr)
if svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=false when resolver errors")
}
})
}
// TestShouldUpgradePassthroughPrepare pins the launchPrepare upgrade decision.
//
// Regression guard for the prompt-delivery break introduced by the passthrough
// upgrade (PR #744): the two-phase create flow does a cheap prompt-less prepare
// followed by an async IntentStartCreated that carries the prompt. If a
// passthrough prepare is eagerly upgraded to a full launch there, the PTY spawns
// with an empty prompt and the prompt-bearing start is rejected against the
// now-running session — so the agent sits at an empty prompt. DeferredStart must
// suppress the upgrade so that follow-up start launches the passthrough WITH the
// prompt, exactly as ACP already does.
func TestShouldUpgradePassthroughPrepare(t *testing.T) {
repo := setupTestRepo(t)
passthrough := &mockAgentManager{isPassthrough: true}
regular := &mockAgentManager{isPassthrough: false}
tests := []struct {
name string
mgr *mockAgentManager
req LaunchSessionRequest
want bool
}{
{
name: "prepare-only passthrough upgrades (quick-chat terminal needs a PTY)",
mgr: passthrough,
req: LaunchSessionRequest{AgentProfileID: "profile1"},
want: true,
},
{
name: "deferred-start passthrough does NOT upgrade (prompt-bearing start follows)",
mgr: passthrough,
req: LaunchSessionRequest{AgentProfileID: "profile1", DeferredStart: true},
want: false,
},
{
name: "auto-start passthrough does NOT upgrade (avoids launchStart bounce)",
mgr: passthrough,
req: LaunchSessionRequest{AgentProfileID: "profile1", AutoStart: true},
want: false,
},
{
name: "non-passthrough never upgrades",
mgr: regular,
req: LaunchSessionRequest{AgentProfileID: "profile1"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), tt.mgr)
if got := svc.shouldUpgradePassthroughPrepare(context.Background(), &tt.req); got != tt.want {
t.Errorf("shouldUpgradePassthroughPrepare()=%v, want %v", got, tt.want)
}
})
}
}
// TestLaunchPrepare_PassthroughDoesNotRecurse guards against an infinite
// launchStart ↔ launchPrepare bounce when a passthrough profile is combined
// with AutoStart=true and a step that blocks auto-start.
//
// To actually exercise the guard, the task must be persisted with a
// WorkflowStepID that maps to a step lacking `auto_start_agent` — otherwise
// `shouldBlockAutoStart` short-circuits to false and the test bypasses the
// downgrade path entirely. Wiring the full scheduler+executor stack is too
// heavy, so we run in a goroutine with a panic recover: stack-overflow
// recursion would never return, while the legitimate downstream nil-deref
// panic from the stub scheduler still completes within the deadline.
func TestLaunchPrepare_PassthroughDoesNotRecurse(t *testing.T) {
repo := setupTestRepo(t)
mgr := &mockAgentManager{isPassthrough: true}
stepGetter := newMockStepGetter()
stepGetter.steps["step-blocked"] = &wfmodels.WorkflowStep{
ID: "step-blocked",
Name: "blocked",
// no on_enter actions => auto_start_agent missing => blocked
}
taskRepo := newMockTaskRepo()
svc := createTestServiceWithAgent(repo, stepGetter, taskRepo, mgr)
// Persist a task with the blocking step so shouldBlockAutoStart returns
// true, forcing launchStart to downgrade into launchPrepare. Without the
// `!req.AutoStart` guard, that re-enters launchStart and recurses.
seedTaskAndSessionWithStep(t, repo, "task1", "sess-pass", "step-blocked")
done := make(chan struct{})
go func() {
defer func() {
// Stub scheduler nil-deref is expected. Recursion would never reach
// this point.
_ = recover()
close(done)
}()
_, _ = svc.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentStart,
AgentProfileID: "profile-pass",
AutoStart: true,
WorkflowStepID: "step-blocked",
})
}()
select {
case <-done:
// returned without recursing
case <-time.After(2 * time.Second):
t.Fatal("LaunchSession recursed indefinitely (timed out)")
}
}
// seedTaskAndSessionWithStep is a variant of seedTaskAndSession that wires
// the task to a workflow step, required by shouldBlockAutoStart.
func seedTaskAndSessionWithStep(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID, stepID string) {
t.Helper()
ctx := context.Background()
now := time.Now().UTC()
ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}
_ = repo.CreateWorkspace(ctx, ws)
wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "Test Workflow", CreatedAt: now, UpdatedAt: now}
_ = repo.CreateWorkflow(ctx, wf)
task := &models.Task{
ID: taskID,
WorkflowID: "wf1",
WorkflowStepID: stepID,
Title: "Test Task",
State: v1.TaskStateInProgress,
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("failed to create task: %v", err)
}
session := &models.TaskSession{
ID: sessionID,
TaskID: taskID,
State: models.TaskSessionStateCreated,
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
}
func TestLaunchRestoreWorkspace_IncludesWorktreeInfo(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
// Add worktree to the session's environment
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env1", TaskID: "task1", ExecutorType: "worktree",
WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady,
}); err != nil {
t.Fatalf("CreateTaskEnvironment: %v", err)
}
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("load session: %v", err)
}
session.TaskEnvironmentID = "env1"
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatalf("link session to environment: %v", err)
}
if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{
ID: "wt1",
TaskEnvironmentID: "env1",
WorktreeID: "wid1",
RepositoryID: "repo1",
WorktreePath: "/tmp/worktrees/session1",
WorktreeBranch: "feature/test",
}); err != nil {
t.Fatalf("failed to create worktree: %v", err)
}
resp, err := svc.LaunchSession(ctx, &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentRestoreWorkspace,
SessionID: "session1",
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if resp.WorktreePath == nil || *resp.WorktreePath != "/tmp/worktrees/session1" {
t.Errorf("expected worktree_path '/tmp/worktrees/session1', got %v", resp.WorktreePath)
}
if resp.WorktreeBranch == nil || *resp.WorktreeBranch != "feature/test" {
t.Errorf("expected worktree_branch 'feature/test', got %v", resp.WorktreeBranch)
}
}
func TestIsBenignLaunchTeardownErr(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{
name: "wrapped context.Canceled",
err: fmt.Errorf("launch failed: %w", context.Canceled),
want: true,
},
{
name: "wrapped ErrSessionTerminal",
err: fmt.Errorf("restore workspace: %w", lifecycle.ErrSessionTerminal),
want: true,
},
{
// resume path stringifies a persisted session error, destroying the
// sentinel (task_operations.go uses %s): the string fallback catches it.
name: "stringified context canceled (resume)",
err: fmt.Errorf("session failed: %s", "context canceled"),
want: true,
},
{
name: "stringified session is terminal",
err: errors.New("session failed: session is terminal"),
want: true,
},
{
name: "unknown task is not benign",
err: errors.New("task not found"),
want: false,
},
{
name: "validation error is not benign",
err: errors.New("task_id is required"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsBenignLaunchTeardownErr(tt.err); got != tt.want {
t.Fatalf("IsBenignLaunchTeardownErr(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}