Files
kandev/apps/backend/internal/orchestrator/task_operations_test.go
T

1883 lines
69 KiB
Go

package orchestrator
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/orchestrator/dto"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/orchestrator/queue"
"github.com/kandev/kandev/internal/orchestrator/scheduler"
"github.com/kandev/kandev/internal/sysprompt"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// seedTaskAndSession inserts a workspace, workflow, task, and session with the given state.
func seedTaskAndSession(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID string, sessionState models.TaskSessionState) {
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",
Title: "Test Task",
Description: "desc",
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: sessionState,
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
}
// --- PromptTask ---
func TestPromptTask_EmptySessionID(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
_, err := svc.PromptTask(context.Background(), "task1", "", "hello", "", false, nil, false)
if err == nil {
t.Fatal("expected error for empty session_id")
}
}
func TestPromptTask_SessionAlreadyRunning(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
_, err := svc.PromptTask(context.Background(), "task1", "session1", "hello", "", false, nil, false)
if err == nil {
t.Fatal("expected error when session is already RUNNING")
}
}
func TestPromptTask_TransientErrorDoesNotMoveTaskToReview(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{
isAgentRunning: true,
promptErr: errors.New("agent stream disconnected: read tcp [::1]:56463->[::1]:10002: use of closed network connection"),
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-1"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-1")
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
_, err = svc.PromptTask(context.Background(), "task1", "session1", "hello", "", false, nil, false)
if err == nil {
t.Fatal("expected transient prompt error")
}
if got, ok := taskRepo.updatedStates["task1"]; ok && got == v1.TaskStateReview {
t.Fatalf("expected task state to avoid REVIEW on transient prompt error, got %q", got)
}
updated, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to reload session: %v", err)
}
if updated.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateWaitingForInput, updated.State)
}
}
// TestPromptTask_CancelEscalatedDoesNotMoveTaskToReview ensures that when the
// lifecycle manager force-unblocks a hung agent (returning ErrCancelEscalated
// wrapped in the agent-error format), PromptTask recognises it as a cancel and
// leaves the task state untouched — the user cancelled, this is not a failure
// the reviewer needs to look at. Service.CancelAgent reconciles session state
// separately; PromptTask must not race ahead with UpdateTaskState(REVIEW).
func TestPromptTask_CancelEscalatedDoesNotMoveTaskToReview(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{
isAgentRunning: true,
promptErr: fmt.Errorf("agent error: cancel escalated: agent did not complete turn within timeout: %w", lifecycle.ErrCancelEscalated),
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-1"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-1")
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
_, err = svc.PromptTask(context.Background(), "task1", "session1", "hello", "", false, nil, false)
if err == nil {
t.Fatal("expected cancel-escalated error to bubble up from PromptTask")
}
if !errors.Is(err, lifecycle.ErrCancelEscalated) {
t.Fatalf("expected ErrCancelEscalated, got: %v", err)
}
if got, ok := taskRepo.updatedStates["task1"]; ok && got == v1.TaskStateReview {
t.Fatalf("expected task state to avoid REVIEW on cancel escalation, got %q", got)
}
}
// TestPromptTask_ExecutionNotFoundRevertsStateAndBroadcasts ensures that when
// Prompt returns executor.ErrExecutionNotFound, PromptTask reverts the session
// state via the broadcasting wrapper (not a direct repo write), so the WS
// subscribers receive session.state_changed and the UI can unstick the
// "Agent is running" composer/pause button.
// Regression test for the stuck-UI bug after a prompt failure.
func TestPromptTask_ExecutionNotFoundRevertsStateAndBroadcasts(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{
isAgentRunning: true,
promptErr: fmt.Errorf("wrapped: %w", lifecycle.ErrExecutionNotFound),
}
eb := &recordingEventBus{}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
svc.eventBus = eb
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-1"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-1")
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
_, err = svc.PromptTask(context.Background(), "task1", "session1", "hello", "", false, nil, false)
if err == nil {
t.Fatal("expected error from prompt, got nil")
}
if !errors.Is(err, executor.ErrExecutionNotFound) {
t.Fatalf("expected ErrExecutionNotFound bubbled up, got: %v", err)
}
updated, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to reload session: %v", err)
}
if updated.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected session state WAITING_FOR_INPUT after revert, got %q", updated.State)
}
var sawRevert bool
for _, evt := range eb.events {
if evt.subject != events.TaskSessionStateChanged {
continue
}
payload, ok := evt.event.Data.(map[string]interface{})
if !ok {
continue
}
oldState, _ := payload["old_state"].(string)
newState, _ := payload["new_state"].(string)
sessID, _ := payload["session_id"].(string)
if sessID == "session1" && oldState == string(models.TaskSessionStateRunning) && newState == string(models.TaskSessionStateWaitingForInput) {
sawRevert = true
break
}
}
if !sawRevert {
t.Fatalf("expected TaskSessionStateChanged RUNNING→WAITING_FOR_INPUT broadcast after prompt failure, got events: %+v", eb.events)
}
}
func TestPromptTask_PlanModeInjectsPrefix(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{
isAgentRunning: true,
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-1"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-1")
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
_, err = svc.PromptTask(context.Background(), "task1", "session1", "update the plan", "", true, nil, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(agentMgr.capturedPrompts) != 1 {
t.Fatalf("expected 1 prompt, got %d", len(agentMgr.capturedPrompts))
}
if !strings.Contains(agentMgr.capturedPrompts[0], "PLAN MODE ACTIVE") {
t.Fatalf("expected plan mode prefix in prompt, got: %s", agentMgr.capturedPrompts[0])
}
}
func TestPromptTask_NoPlanModeDoesNotInjectPrefix(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{
isAgentRunning: true,
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, err := repo.GetTaskSession(context.Background(), "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-1"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-1")
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
_, err = svc.PromptTask(context.Background(), "task1", "session1", "implement the feature", "", false, nil, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(agentMgr.capturedPrompts) != 1 {
t.Fatalf("expected 1 prompt, got %d", len(agentMgr.capturedPrompts))
}
if strings.Contains(agentMgr.capturedPrompts[0], "PLAN MODE ACTIVE") {
t.Fatalf("expected no plan mode prefix in prompt, got: %s", agentMgr.capturedPrompts[0])
}
}
func TestPromptTask_ResetInProgressReturnsSentinelError(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
svc.setSessionResetInProgress("session1", true)
defer svc.setSessionResetInProgress("session1", false)
_, err := svc.PromptTask(context.Background(), "task1", "session1", "hello", "", false, nil, false)
if !errors.Is(err, ErrSessionResetInProgress) {
t.Fatalf("expected ErrSessionResetInProgress, got %v", err)
}
}
// --- CancelAgent ---
// TestCancelAgent_DeduplicatesConcurrentCalls covers the impatient-user case:
// the UI's cancel button has no in-flight disable, so users click it multiple
// times while the agent is still tearing down a slow turn (e.g. a Claude
// Monitor tool). Without dedupe each click reaches the lifecycle layer and
// emits its own "Turn cancelled by user" message; phantom turns are also
// lazily started to host those messages. We assert that only one cancel makes
// it through to agentManager.CancelAgent while one is already in flight.
func TestCancelAgent_DeduplicatesConcurrentCalls(t *testing.T) {
repo := setupTestRepo(t)
agentMgr := &mockAgentManager{
isAgentRunning: true,
cancelAgentBlock: make(chan struct{}),
cancelAgentEntered: make(chan struct{}, 1),
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
// First call goes async and parks inside agentManager.CancelAgent.
firstDone := make(chan error, 1)
go func() {
firstDone <- svc.CancelAgent(context.Background(), "session1")
}()
// Wait for the first call to actually enter agentManager.CancelAgent so
// the dedupe guard is set before the duplicate calls fire. Channel sync
// (over sleep-based polling) is the project convention for tests that
// don't depend on real subprocess timing.
<-agentMgr.cancelAgentEntered
// Fire several duplicates while the first is still parked. Each must be
// short-circuited by the dedupe guard and return immediately.
const duplicates = 5
for i := 0; i < duplicates; i++ {
if err := svc.CancelAgent(context.Background(), "session1"); err != nil {
t.Fatalf("duplicate cancel %d returned error: %v", i, err)
}
}
if got := agentMgr.cancelAgentCalls.Load(); got != 1 {
t.Fatalf("expected exactly 1 agentManager.CancelAgent call while first is in flight, got %d", got)
}
// Release the first call. After it returns, the guard clears and a fresh
// cancel is allowed through.
close(agentMgr.cancelAgentBlock)
if err := <-firstDone; err != nil {
t.Fatalf("first CancelAgent returned error: %v", err)
}
agentMgr.cancelAgentBlock = nil // unblock subsequent calls
if err := svc.CancelAgent(context.Background(), "session1"); err != nil {
t.Fatalf("post-release CancelAgent returned error: %v", err)
}
if got := agentMgr.cancelAgentCalls.Load(); got != 2 {
t.Fatalf("expected 2 agentManager.CancelAgent calls after release, got %d", got)
}
}
// TestCancelAgent_TaskStateReconcile ensures cancel lands actively-working
// kanban tasks in REVIEW (treated as finished work the user may want to
// review). Office tasks and tasks already out of IN_PROGRESS / SCHEDULING
// must be left untouched.
func TestCancelAgent_TaskStateReconcile(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
taskState v1.TaskState
office bool
wantStateUpdate bool
}{
{name: "in_progress", taskState: v1.TaskStateInProgress, wantStateUpdate: true},
{name: "scheduling", taskState: v1.TaskStateScheduling, wantStateUpdate: true},
{name: "review", taskState: v1.TaskStateReview},
{name: "office", taskState: v1.TaskStateInProgress, office: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{isAgentRunning: true}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
taskID := "task-" + tc.name
sessionID := "session-" + tc.name
if tc.office {
seedOfficeSession(t, repo, taskID, sessionID, "")
// Seed the mock so a missing office guard would fail the test: without
// AssigneeAgentProfileID early-return, UpdateTaskStateIfCurrentIn would
// run against this IN_PROGRESS row and write updatedStates.
taskRepo.tasks[taskID] = &v1.Task{ID: taskID, State: tc.taskState}
} else {
seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
task, err := repo.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task: %v", err)
}
task.State = tc.taskState
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("update task state: %v", err)
}
taskRepo.tasks[taskID] = &v1.Task{ID: taskID, State: tc.taskState}
}
if err := svc.CancelAgent(ctx, sessionID); err != nil {
t.Fatalf("cancel agent: %v", err)
}
got, ok := taskRepo.updatedStates[taskID]
if tc.wantStateUpdate {
if !ok {
t.Fatal("expected tasks.state to be updated on cancel")
}
if got != v1.TaskStateReview {
t.Fatalf("expected task state %q, got %q", v1.TaskStateReview, got)
}
return
}
if ok {
t.Fatalf("expected tasks.state to remain unchanged, got %q", got)
}
})
}
}
func TestCancelAgent_LeavesQueuedMessageForManualDrain(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
agentMgr := &mockAgentManager{isAgentRunning: true}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
seedExecutorRunning(t, repo, "session1", "task1", "exec-1")
if _, err := svc.messageQueue.QueueMessage(
ctx, "session1", "task1", "queued after cancel", "", messagequeue.QueuedByUser, false, nil,
); err != nil {
t.Fatalf("queue message: %v", err)
}
if err := svc.CancelAgent(ctx, "session1"); err != nil {
t.Fatalf("cancel agent: %v", err)
}
updated, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("get updated session: %v", err)
}
if updated.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateWaitingForInput, updated.State)
}
status := svc.messageQueue.GetStatus(ctx, "session1")
if status.Count != 1 {
t.Fatalf("expected cancel to leave queued message for manual drain, count=%d entries=%+v", status.Count, status.Entries)
}
if got := status.Entries[0].Content; got != "queued after cancel" {
t.Fatalf("queued prompt = %q, want %q", got, "queued after cancel")
}
if len(agentMgr.capturedPrompts) != 0 {
t.Fatalf("expected cancel not to prompt queued message, got %d prompts", len(agentMgr.capturedPrompts))
}
}
// --- StartCreatedSession ---
func TestStartCreatedSession_WrongTask(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
// Session belongs to "task-other", not "task1"
seedTaskAndSession(t, repo, "task-other", "session1", models.TaskSessionStateCreated)
_, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false, false, nil)
if err == nil {
t.Fatal("expected error when session does not belong to task")
}
}
func TestStartCreatedSession_NotInCreatedState(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
_, err := svc.StartCreatedSession(context.Background(), "task1", "session1", "profile1", "prompt", false, false, false, nil)
if err == nil {
t.Fatal("expected error when session is not in CREATED state")
}
}
// --- recordInitialMessage ---
// mockMessageCreator implements MessageCreator for testing.
// Only CreateUserMessage is tracked; all other methods are no-op stubs.
type mockMessageCreator struct {
userMessages []mockUserMessage
sessionMessages []mockSessionMessage
}
type mockUserMessage struct {
taskID, content, sessionID, turnID string
metadata map[string]interface{}
}
type mockSessionMessage struct {
taskID, content, sessionID, messageType, turnID string
metadata map[string]interface{}
requestsInput bool
}
func (m *mockMessageCreator) CreateUserMessage(_ context.Context, taskID, content, sessionID, turnID string, metadata map[string]interface{}) error {
m.userMessages = append(m.userMessages, mockUserMessage{taskID, content, sessionID, turnID, metadata})
return nil
}
func (m *mockMessageCreator) CreateAgentMessage(context.Context, string, string, string, string) error {
return nil
}
func (m *mockMessageCreator) CreateToolCallMessage(context.Context, string, string, string, string, string, string, string, *streams.NormalizedPayload) error {
return nil
}
func (m *mockMessageCreator) UpdateToolCallMessage(context.Context, string, string, string, string, string, string, string, string, string, *streams.NormalizedPayload) error {
return nil
}
func (m *mockMessageCreator) CreateSessionMessage(_ context.Context, taskID, content, sessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
m.sessionMessages = append(m.sessionMessages, mockSessionMessage{
taskID: taskID,
content: content,
sessionID: sessionID,
messageType: messageType,
turnID: turnID,
metadata: metadata,
requestsInput: requestsInput,
})
return nil
}
func (m *mockMessageCreator) CreatePermissionRequestMessage(context.Context, string, string, string, string, string, string, []map[string]interface{}, string, map[string]interface{}) (string, error) {
return "", nil
}
func (m *mockMessageCreator) UpdatePermissionMessage(context.Context, string, string, models.PermissionStatus) error {
return nil
}
func (m *mockMessageCreator) CreateAgentMessageStreaming(context.Context, string, string, string, string, string) error {
return nil
}
func (m *mockMessageCreator) AppendAgentMessage(context.Context, string, string) error {
return nil
}
func (m *mockMessageCreator) CreateThinkingMessageStreaming(context.Context, string, string, string, string, string) error {
return nil
}
func (m *mockMessageCreator) AppendThinkingMessage(context.Context, string, string) error {
return nil
}
func (m *mockMessageCreator) InvalidateModelCache(string) {}
// --- backfillInitialUserMessageIfMissing ---
func TestBackfillInitialUserMessageIfMissing_RecordsWhenSessionEmpty(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
// Session has zero messages — backfill should record the prompt.
svc.backfillInitialUserMessageIfMissing(ctx, "task1", "session1", "original prompt")
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message recorded, got %d", len(mc.userMessages))
}
if mc.userMessages[0].content != "original prompt" {
t.Errorf("content = %q, want %q", mc.userMessages[0].content, "original prompt")
}
}
func TestBackfillInitialUserMessageIfMissing_SkipsWhenUserMessageExists(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
// Seed an existing user message — the backfill must be a no-op so a
// successful prior launch isn't duplicated on a subsequent resume.
if err := repo.CreateTurn(ctx, &models.Turn{ID: "turn1", TaskSessionID: "session1", TaskID: "task1", StartedAt: time.Now().UTC()}); err != nil {
t.Fatalf("create turn: %v", err)
}
if err := repo.CreateMessage(ctx, &models.Message{
ID: "msg1",
TaskSessionID: "session1",
TaskID: "task1",
TurnID: "turn1",
AuthorType: models.MessageAuthorUser,
Content: "user already sent this",
CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create message: %v", err)
}
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.backfillInitialUserMessageIfMissing(ctx, "task1", "session1", "would be a duplicate")
if len(mc.userMessages) != 0 {
t.Fatalf("expected no user message recorded (one already exists), got %d", len(mc.userMessages))
}
}
// TestBackfillInitialUserMessageIfMissing_SkipsWhenAgentMessageExists covers
// the regression where a partial prior run produced agent output but never
// recorded the initial user message. Recording the user message now with
// CreatedAt=time.Now() would place it at the bottom of the chat (after the
// agent messages), which is worse than leaving the chat alone.
func TestBackfillInitialUserMessageIfMissing_SkipsWhenAgentMessageExists(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
if err := repo.CreateTurn(ctx, &models.Turn{ID: "turn1", TaskSessionID: "session1", TaskID: "task1", StartedAt: time.Now().UTC()}); err != nil {
t.Fatalf("create turn: %v", err)
}
if err := repo.CreateMessage(ctx, &models.Message{
ID: "agent-msg-1",
TaskSessionID: "session1",
TaskID: "task1",
TurnID: "turn1",
AuthorType: models.MessageAuthorAgent,
Content: "agent partial output from a prior run",
CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create agent message: %v", err)
}
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.backfillInitialUserMessageIfMissing(ctx, "task1", "session1", "the original prompt")
if len(mc.userMessages) != 0 {
t.Fatalf("expected no backfill when agent messages exist, got %d", len(mc.userMessages))
}
}
func TestBackfillInitialUserMessageIfMissing_SkipsEmptyPrompt(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.backfillInitialUserMessageIfMissing(ctx, "task1", "session1", "")
if len(mc.userMessages) != 0 {
t.Fatalf("expected no user message for empty prompt, got %d", len(mc.userMessages))
}
}
func TestRecordInitialMessage_DoesNotChangeSessionState(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.recordInitialMessage(ctx, "task1", "session1", "hello world", false, false, nil)
// Session state must remain STARTING — recordInitialMessage should not modify state.
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
if session.State != models.TaskSessionStateStarting {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateStarting, session.State)
}
// User message should have been created.
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message, got %d", len(mc.userMessages))
}
if mc.userMessages[0].content != "hello world" {
t.Fatalf("expected message content %q, got %q", "hello world", mc.userMessages[0].content)
}
}
func TestPostLaunchCreated_SkipMessage_DoesNotChangeSessionState(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.postLaunchCreated(ctx, "task1", "session1", "prompt", true, false, false, nil)
// Session state must remain STARTING when skipMessage=true.
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
if session.State != models.TaskSessionStateStarting {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateStarting, session.State)
}
}
func TestPostLaunchCreated_WithMessage_DoesNotChangeSessionState(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.postLaunchCreated(ctx, "task1", "session1", "hello", false, false, false, nil)
// Session state must remain STARTING — postLaunchCreated delegates to
// recordInitialMessage which only creates the message.
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
if session.State != models.TaskSessionStateStarting {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateStarting, session.State)
}
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message, got %d", len(mc.userMessages))
}
}
func TestPostLaunchCreated_AutoStart_SetsMetadata(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
// autoStart=true should land an `auto_start: true` tag on the
// recorded user message so HasUserAuthoredMessage skips it. This
// asserts the metadata wiring in recordInitialMessage directly —
// the broader behavior is tested in cmd/kandev TestHasUserAuthoredMessage.
svc.postLaunchCreated(ctx, "task1", "session1", "auto-started by workflow", false, false, true, nil)
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message, got %d", len(mc.userMessages))
}
if mc.userMessages[0].metadata["auto_start"] != true {
t.Fatalf("expected auto_start=true in metadata, got %v", mc.userMessages[0].metadata)
}
}
func TestPostLaunchCreated_PlanMode_SetsMetadata(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting)
mc := &mockMessageCreator{}
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.messageCreator = mc
svc.postLaunchCreated(ctx, "task1", "session1", "plan this", false, true, false, nil)
// User message should have plan_mode metadata.
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message, got %d", len(mc.userMessages))
}
if mc.userMessages[0].metadata["plan_mode"] != true {
t.Fatalf("expected plan_mode=true in metadata, got %v", mc.userMessages[0].metadata)
}
// Session metadata should contain plan_mode.
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
if session.Metadata == nil {
t.Fatal("expected session metadata to be set")
}
if session.Metadata["plan_mode"] != true {
t.Fatalf("expected plan_mode=true in session metadata, got %v", session.Metadata["plan_mode"])
}
}
// --- StartCreatedSession: Kandev system prompt wrap on first launch ---
// TestStartCreatedSession_WrapsFirstPromptWithKandevSystemBlock verifies that
// the recorded user message persists the <kandev-system> wrap that the
// orchestrator now injects in startTask / StartCreatedSession. The wrap must
// be in the raw row so the chat UI can show it under "Show formatted" and the
// agent CLI's first ACP prompt includes the MCP tools list and task/session
// IDs. Regression guard for the case the user reported: "tasks I create from
// the kanban mode don't have the kandev system prompt."
func TestStartCreatedSession_WrapsFirstPromptWithKandevSystemBlock(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated)
// Seed executors_running so LaunchPreparedSession takes the fast path
// (startAgentOnExistingWorkspace) and never reaches the real LaunchAgent.
seedExecutorRunning(t, repo, "session1", "task1", "exec-1")
taskRepo := newMockTaskRepo()
taskRepo.tasks["task1"] = &v1.Task{
ID: "task1",
Title: "Test Task",
Description: "Original task description",
State: v1.TaskStateInProgress,
}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
_, err := svc.StartCreatedSession(ctx, "task1", "session1", "profile1", "Build me a feature", false, false, false, nil)
if err != nil {
t.Fatalf("StartCreatedSession failed: %v", err)
}
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message recorded, got %d", len(mc.userMessages))
}
content := mc.userMessages[0].content
// The wrap is the outermost layer; the user's typed text must still be inside it.
if !strings.Contains(content, "<kandev-system>") {
t.Errorf("expected <kandev-system> opening tag in recorded content, got %q", content)
}
if !strings.Contains(content, "</kandev-system>") {
t.Errorf("expected </kandev-system> closing tag in recorded content, got %q", content)
}
if !strings.Contains(content, "Build me a feature") {
t.Errorf("expected user text preserved in recorded content, got %q", content)
}
// The wrap must carry the task and session IDs so the agent can call the
// kandev MCP tools without re-discovering its own identifiers.
if !strings.Contains(content, "Kandev Task ID: task1") {
t.Errorf("expected Kandev Task ID in wrap, got %q", content)
}
if !strings.Contains(content, "Kandev Session ID: session1") {
t.Errorf("expected Kandev Session ID in wrap, got %q", content)
}
// The MCP tool list is the whole point of the wrap — guard a representative one.
if !strings.Contains(content, "ask_user_question_kandev") {
t.Errorf("expected ask_user_question_kandev tool in wrap, got %q", content)
}
}
// TestStartCreatedSession_DoesNotDoubleWrapPreWrappedPrompt verifies the
// idempotency guard on the orchestrator's wrap step. Upstream call sites
// (wsAddMessage on CREATED sessions, recordAutoStartMessage) wrap before
// recording the user message so the DB row carries the <kandev-system>
// block. When the wrapped content is later passed through StartCreatedSession,
// the orchestrator must NOT wrap it a second time — otherwise the agent
// receives nested system blocks and the strip pipeline behaves unpredictably.
func TestStartCreatedSession_DoesNotDoubleWrapPreWrappedPrompt(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated)
seedExecutorRunning(t, repo, "session1", "task1", "exec-1")
taskRepo := newMockTaskRepo()
taskRepo.tasks["task1"] = &v1.Task{
ID: "task1",
Title: "Test Task",
State: v1.TaskStateInProgress,
}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
// Simulate an upstream caller (e.g. wsAddMessage) that has already wrapped.
preWrapped := sysprompt.InjectKandevContext("task1", "session1", "Build me a feature")
_, err := svc.StartCreatedSession(ctx, "task1", "session1", "profile1", preWrapped, false, false, false, nil)
if err != nil {
t.Fatalf("StartCreatedSession failed: %v", err)
}
if len(mc.userMessages) != 1 {
t.Fatalf("expected 1 user message recorded, got %d", len(mc.userMessages))
}
content := mc.userMessages[0].content
// Exactly one opening tag and one closing tag — not nested.
openCount := strings.Count(content, "<kandev-system>")
closeCount := strings.Count(content, "</kandev-system>")
if openCount != 1 {
t.Errorf("expected exactly 1 <kandev-system> tag, got %d in %q", openCount, content)
}
if closeCount != 1 {
t.Errorf("expected exactly 1 </kandev-system> tag, got %d in %q", closeCount, content)
}
// The user's text is preserved.
if !strings.Contains(content, "Build me a feature") {
t.Errorf("expected user text preserved, got %q", content)
}
}
// TestStartCreatedSession_EmptyPromptSkipsWrap verifies the orchestrator does
// not synthesize a <kandev-system>-only message when the user has nothing to
// say yet. recordInitialMessage already skips empty prompts, but wrapping
// "" would defeat that guard and pollute the chat with a tag-only row.
func TestStartCreatedSession_EmptyPromptSkipsWrap(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated)
seedExecutorRunning(t, repo, "session1", "task1", "exec-1")
taskRepo := newMockTaskRepo()
// No description on the task and no prompt from the caller — startTask's
// `effectivePrompt == ""` branch must short-circuit before InjectKandevContext.
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", Title: "Empty", State: v1.TaskStateInProgress}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
_, err := svc.StartCreatedSession(ctx, "task1", "session1", "profile1", "", false, false, false, nil)
if err != nil {
t.Fatalf("StartCreatedSession failed: %v", err)
}
// No user message should be recorded — wrapping an empty prompt would
// produce a tag-only row.
if len(mc.userMessages) != 0 {
t.Fatalf("expected 0 user messages for empty prompt, got %d (content=%q)",
len(mc.userMessages), mc.userMessages[0].content)
}
}
// --- ResumeTaskSession ---
func TestResumeTaskSession_WrongTask(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
seedTaskAndSession(t, repo, "task-other", "session1", models.TaskSessionStateWaitingForInput)
_, err := svc.ResumeTaskSession(context.Background(), "task1", "session1")
if err == nil {
t.Fatal("expected error when session does not belong to task")
}
}
func TestResumeTaskSession_NotResumable(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
// Session exists and belongs to task, but there is no ExecutorRunning record
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
_, err := svc.ResumeTaskSession(context.Background(), "task1", "session1")
if err == nil {
t.Fatal("expected error when no executor running record exists")
}
}
func TestResumeTaskSession_ArchivedTaskSkipsFailedState(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
// Archive the task after seeding
if err := repo.ArchiveTask(ctx, "task1"); err != nil {
t.Fatalf("failed to archive task: %v", err)
}
// Insert executor running record so we pass the "not resumable" check
now := time.Now().UTC()
_ = repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1", SessionID: "session1", TaskID: "task1",
CreatedAt: now, UpdatedAt: now,
})
_, err := svc.ResumeTaskSession(ctx, "task1", "session1")
if !errors.Is(err, executor.ErrTaskArchived) {
t.Fatalf("expected ErrTaskArchived, got: %v", err)
}
// Task state should NOT have been updated to FAILED
if _, ok := taskRepo.updatedStates["task1"]; ok {
t.Error("task state should not be updated when task is archived")
}
}
func TestResumeTaskSession_ArchivedDuringLaunch(t *testing.T) {
// Simulates the race: task is NOT archived when the executor checks,
// but LaunchAgent fails (archive's async cleanup killed the agent),
// and by the time the error path re-reads the task it IS archived.
ctx := context.Background()
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{
launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
// Simulate archive completing while launch is in progress:
// archive the task, then fail the launch (as if async cleanup killed the process).
_ = repo.ArchiveTask(ctx, "task1")
return nil, fmt.Errorf("connection refused")
},
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
// Set agent profile ID so the executor doesn't reject the session early
session, _ := repo.GetTaskSession(ctx, "session1")
session.AgentProfileID = "profile-1"
_ = repo.UpdateTaskSession(ctx, session)
_ = repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1", SessionID: "session1", TaskID: "task1",
CreatedAt: now, UpdatedAt: now,
})
_, err := svc.ResumeTaskSession(ctx, "task1", "session1")
if !errors.Is(err, executor.ErrTaskArchived) {
t.Fatalf("expected ErrTaskArchived, got: %v", err)
}
// Task state should NOT have been updated to FAILED
if _, ok := taskRepo.updatedStates["task1"]; ok {
t.Error("task state should not be updated when task is archived during launch")
}
}
// TestResumeTaskSession_FailedKeepsResumeToken verifies that resuming a FAILED
// session preserves the ACP resume token so the relaunched agent restores the
// prior conversation via ACP session/load (for native-resume agents).
// Regression test for the "Resume blocked by stale state" bug where FAILED sessions
// couldn't be restarted at all; the fix also changes policy to keep the token
// (previously it was cleared to force a fresh agent).
func TestResumeTaskSession_FailedKeepsResumeToken(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
// Agent launch succeeds so the resume path does not unwind and mark the task
// FAILED, which would exercise a separate state-mutation code path.
agentMgr := &mockAgentManager{
launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
return &executor.LaunchAgentResponse{
AgentExecutionID: "exec-new",
Status: v1.AgentStatusStarting,
}, nil
},
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
session, _ := repo.GetTaskSession(ctx, "session1")
session.AgentProfileID = "profile-1"
_ = repo.UpdateTaskSession(ctx, session)
now := time.Now().UTC()
_ = repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1", SessionID: "session1", TaskID: "task1",
ResumeToken: "acp-session-xyz",
Resumable: true,
CreatedAt: now, UpdatedAt: now,
})
if _, err := svc.ResumeTaskSession(ctx, "task1", "session1"); err != nil {
t.Fatalf("ResumeTaskSession on FAILED session returned: %v", err)
}
er, err := repo.GetExecutorRunningBySessionID(ctx, "session1")
if err != nil || er == nil {
t.Fatalf("ExecutorRunning lookup failed: %v (nil=%v)", err, er == nil)
}
if er.ResumeToken != "acp-session-xyz" {
t.Errorf("expected resume token to be preserved on FAILED resume, got %q", er.ResumeToken)
}
}
// ctxAwareTaskRepo wraps mockTaskRepo and respects ctx cancellation. Used to
// prove that ResumeTaskSession's failure-recording path is insulated from a
// pre-cancelled caller ctx (the WS-disconnect scenario).
type ctxAwareTaskRepo struct {
inner *mockTaskRepo
}
func (c *ctxAwareTaskRepo) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return c.inner.GetTask(ctx, taskID)
}
func (c *ctxAwareTaskRepo) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
if err := ctx.Err(); err != nil {
return err
}
return c.inner.UpdateTaskState(ctx, taskID, state)
}
func (c *ctxAwareTaskRepo) UpdateTaskStateIfCurrentIn(
ctx context.Context, taskID string, state v1.TaskState, allowed []v1.TaskState,
) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
return c.inner.UpdateTaskStateIfCurrentIn(ctx, taskID, state, allowed)
}
// TestResumeTaskSession_FailedStateWriteSurvivesCancelledCallerCtx verifies the
// fix for the WS-disconnect cascade: when the caller's ctx was already
// cancelled (e.g. the user navigated away mid-resume) and the launch then
// failed, the FAILED state-update writes must still go through using the
// detached resumeCtx — otherwise the task is stuck looking "running" forever.
//
// Before the fix, lines 886-892 used the original ctx, so the failure-state
// write itself returned "context canceled" and the WARN "failed to update task
// state to FAILED after resume error: context canceled" appeared in the logs.
func TestResumeTaskSession_FailedStateWriteSurvivesCancelledCallerCtx(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
repo := setupTestRepo(t)
mockTR := newMockTaskRepo()
taskRepo := &ctxAwareTaskRepo{inner: mockTR}
// Cancel the caller ctx the moment launch is invoked. This mirrors the
// WS-disconnect race: the request handler's ctx is alive when the
// resume path starts (so it gets through the early-exit checks against
// sqlite/etc.) and dies mid-launch. The post-launch failure-recording
// writes use resumeCtx (WithoutCancel) and must still succeed.
agentMgr := &mockAgentManager{
launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
cancel()
return nil, errors.New("simulated launch failure")
},
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), mockTR, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
// Override with the ctx-aware wrapper so we can detect ctx-canceled
// writes — the bare mockTaskRepo ignores ctx entirely.
svc.taskRepo = taskRepo
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, _ := repo.GetTaskSession(ctx, "session1")
session.AgentProfileID = "profile-1"
_ = repo.UpdateTaskSession(ctx, session)
now := time.Now().UTC()
_ = repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1", SessionID: "session1", TaskID: "task1",
CreatedAt: now, UpdatedAt: now,
})
_, err := svc.ResumeTaskSession(ctx, "task1", "session1")
if err == nil {
t.Fatal("expected ResumeTaskSession to return an error from the simulated launch failure")
}
state, ok := mockTR.updatedStates["task1"]
if !ok {
t.Fatal("task FAILED state was NOT persisted; the failure-recording write was cancelled by the caller ctx")
}
if state != v1.TaskStateFailed {
t.Errorf("expected task1 state=FAILED, got %v", state)
}
persisted, getErr := repo.GetTaskSession(context.Background(), "session1")
if getErr != nil {
t.Fatalf("failed to reload session: %v", getErr)
}
if persisted.State != models.TaskSessionStateFailed {
t.Errorf("expected session1 state=FAILED, got %v", persisted.State)
}
}
// --- CompleteTask ---
func TestCompleteTask_UpdatesTaskState(t *testing.T) {
repo := setupTestRepo(t)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
exec := executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = exec
err := svc.CompleteTask(context.Background(), "task1")
if err != nil {
t.Fatalf("CompleteTask returned unexpected error: %v", err)
}
if state, ok := taskRepo.updatedStates["task1"]; !ok || state != v1.TaskStateCompleted {
t.Errorf("expected task state COMPLETED, got %v (ok=%v)", state, ok)
}
}
// --- Error Classification Functions ---
func TestErrorClassificationFunctions(t *testing.T) {
t.Run("isAgentPromptInProgressError", func(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"unrelated error", errors.New("something else"), false},
{"exact match", ErrAgentPromptInProgress, true},
{"wrapped error", fmt.Errorf("outer: %w", ErrAgentPromptInProgress), true},
{"untyped string match no longer accepted", errors.New("prefix: agent is currently processing a prompt, try later"), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isAgentPromptInProgressError(tc.err); got != tc.want {
t.Errorf("isAgentPromptInProgressError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
})
t.Run("isSessionResetInProgressError", func(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"unrelated error", errors.New("something else"), false},
{"exact match", ErrSessionResetInProgress, true},
{"wrapped error", fmt.Errorf("outer: %w", ErrSessionResetInProgress), true},
{"untyped string match no longer accepted", errors.New("prefix: session reset in progress, please wait"), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isSessionResetInProgressError(tc.err); got != tc.want {
t.Errorf("isSessionResetInProgressError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
})
t.Run("isAgentAlreadyRunningError", func(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"unrelated error", errors.New("something else"), false},
{"lifecycle manager error", fmt.Errorf("%w: session %q (execution: %s)", lifecycle.ErrAgentAlreadyRunning, "s1", "exec-1"), true},
{"wrapped error", fmt.Errorf("failed to resume session: %w", fmt.Errorf("%w: session %q (execution: %s)", lifecycle.ErrAgentAlreadyRunning, "s1", "exec-1")), true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isAgentAlreadyRunningError(tc.err); got != tc.want {
t.Errorf("isAgentAlreadyRunningError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
})
t.Run("isTransientPromptError", func(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"unrelated error", errors.New("something else"), false},
{"agent stream disconnected", errors.New("agent stream disconnected: read tcp"), true},
{"use of closed network connection", errors.New("write: use of closed network connection"), true},
{"case insensitive match", errors.New("Agent Stream Disconnected: EOF"), true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isTransientPromptError(tc.err); got != tc.want {
t.Errorf("isTransientPromptError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
})
}
// --- GetTaskSessionStatus ---
func TestGetTaskSessionStatus_HealsStuckStartingSession(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateStarting)
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-active"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-active")
session.UpdatedAt = time.Now().UTC()
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
now := time.Now().UTC()
if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
Status: "ready",
Resumable: true,
AgentExecutionID: "exec-active",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", State: v1.TaskStateInProgress}
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1")
if err != nil {
t.Fatalf("GetTaskSessionStatus returned error: %v", err)
}
if resp.State != string(models.TaskSessionStateWaitingForInput) {
t.Fatalf("expected response state %q, got %q", models.TaskSessionStateWaitingForInput, resp.State)
}
updated, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to reload session: %v", err)
}
if updated.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected persisted session state %q, got %q", models.TaskSessionStateWaitingForInput, updated.State)
}
if resp.UpdatedAt != updated.UpdatedAt.UTC().Format(time.RFC3339Nano) {
t.Fatalf("expected response updated_at %q, got %q", updated.UpdatedAt.UTC().Format(time.RFC3339Nano), resp.UpdatedAt)
}
if state, ok := taskRepo.updatedStates["task1"]; !ok || state != v1.TaskStateReview {
t.Fatalf("expected task state %q, got %q (ok=%v)", v1.TaskStateReview, state, ok)
}
}
// TestGetTaskSessionStatus_DoesNotHealOnMismatchedExecution was removed.
// The pre-refactor heal check skipped healing when session.AgentExecutionID and
// running.AgentExecutionID disagreed — a band-aid for the very divergence bug
// this PR fixes structurally. With executors_running as the single source of
// truth (lifecycle-owned, persisted in lockstep with executionStore.Add), the
// mismatch this test simulated cannot occur, and the band-aid was removed
// (see shouldHealStuckStartingSession in task_operations.go).
func TestGetTaskSessionStatus_UsesTaskEnvironmentBranchForDocker(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.TaskEnvironmentID = "env1"
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
now := time.Now().UTC()
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env1",
TaskID: "task1",
ExecutorType: string(models.ExecutorTypeLocalDocker),
WorktreePath: "/workspace",
WorktreeBranch: "feature/test-task-abc",
WorkspacePath: "/workspace",
Status: models.TaskEnvironmentStatusReady,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("failed to create task environment: %v", err)
}
agentMgr := &mockAgentManager{}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1")
if err != nil {
t.Fatalf("GetTaskSessionStatus returned error: %v", err)
}
if resp.WorktreeBranch == nil || *resp.WorktreeBranch != "feature/test-task-abc" {
t.Fatalf("worktree_branch = %v, want feature/test-task-abc", resp.WorktreeBranch)
}
if resp.WorktreePath == nil || *resp.WorktreePath != "/workspace" {
t.Fatalf("worktree_path = %v, want /workspace", resp.WorktreePath)
}
}
// --- ReconcileSessionsOnStartup ---
func TestReconcileSessionsOnStartup(t *testing.T) {
t.Run("terminal_session_cleaned_up", func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted)
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
_, err = repo.GetExecutorRunningBySessionID(ctx, "session1")
if err == nil {
t.Fatal("expected ExecutorRunning record to be deleted for terminal session")
}
})
t.Run("active_session_set_to_waiting", func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
if session.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateWaitingForInput, session.State)
}
// ExecutorRunning should be preserved for lazy resume
_, err = repo.GetExecutorRunningBySessionID(ctx, "session1")
if err != nil {
t.Fatalf("expected ExecutorRunning record to be preserved, got error: %v", err)
}
})
t.Run("failed_session_with_resume_token_preserved", func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateFailed)
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
ResumeToken: "acp-session-abc",
Resumable: true,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["task1"] = &v1.Task{
ID: "task1",
State: v1.TaskStateReview,
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
// ExecutorRunning should be preserved because it has a resume token and is resumable
er, err := repo.GetExecutorRunningBySessionID(ctx, "session1")
if err != nil {
t.Fatalf("expected ExecutorRunning to be preserved for resumable failed session, got error: %v", err)
}
if er.ResumeToken != "acp-session-abc" {
t.Fatalf("expected resume token to be preserved, got %q", er.ResumeToken)
}
})
// Pins office IDLE preservation: an office session sitting in IDLE
// (agent torn down between turns, conversation parked for the next
// run) MUST stay IDLE after backend restart. The previous code
// path flipped any non-WAITING_FOR_INPUT active state — including
// IDLE — to WAITING_FOR_INPUT, which made the chat UI render as
// "Agent working" on a restored task even when nothing was running.
t.Run("idle_office_session_state_preserved", func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task-idle", "session-idle", models.TaskSessionStateIdle)
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er-idle",
SessionID: "session-idle",
TaskID: "task-idle",
ResumeToken: "acp-session-xyz",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
session, err := repo.GetTaskSession(ctx, "session-idle")
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
if session.State != models.TaskSessionStateIdle {
t.Fatalf("expected IDLE to be preserved, got %q", session.State)
}
// ExecutorRunning row must be preserved — the resume token is
// what powers the next run's session/load.
er, err := repo.GetExecutorRunningBySessionID(ctx, "session-idle")
if err != nil {
t.Fatalf("expected ExecutorRunning to be preserved for IDLE office session: %v", err)
}
if er.ResumeToken != "acp-session-xyz" {
t.Fatalf("expected resume token to be preserved, got %q", er.ResumeToken)
}
})
t.Run("task_in_progress_moved_to_review", func(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
now := time.Now().UTC()
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateRunning)
err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1",
SessionID: "session1",
TaskID: "task1",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["task1"] = &v1.Task{
ID: "task1",
State: v1.TaskStateInProgress,
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
svc.reconcileSessionsOnStartup(ctx)
state, ok := taskRepo.updatedStates["task1"]
if !ok {
t.Fatal("expected task state to be updated")
}
if state != v1.TaskStateReview {
t.Fatalf("expected task state %q, got %q", v1.TaskStateReview, state)
}
})
}
// --- ensureSessionRunning: prepared workspace ---
func TestEnsureSessionRunning_PreparedWorkspace(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
// Seed task and session in CREATED state (workspace prepared, agent not started)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated)
// Set AgentExecutionID to simulate a prepared workspace
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.AgentExecutionID = "exec-prepare-1"
seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-prepare-1")
session.AgentProfileID = "profile1"
if err := repo.UpdateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
// Create a wrapped mock agent manager that transitions the session to WAITING_FOR_INPUT
// when StartAgentProcess is called (simulating the agent starting successfully).
startAgentProcessCalled := false
wrappedMgr := &sessionUpdatingAgentManager{
mockAgentManager: &mockAgentManager{
isAgentRunning: false,
// Return the execution ID so the existing-workspace path proceeds
getExecutionIDForSessionFunc: func(_ context.Context, sid string) (string, error) {
if sid == "session1" {
return "exec-prepare-1", nil
}
return "", fmt.Errorf("no execution found")
},
},
repo: repo,
sessionID: "session1",
taskID: "task1",
onStartCalled: &startAgentProcessCalled,
}
taskRepo := newMockTaskRepo()
taskRepo.tasks["task1"] = &v1.Task{
ID: "task1",
Title: "Test Task",
Description: "desc",
State: v1.TaskStateInProgress,
}
log := testLogger()
exec := executor.NewExecutor(wrappedMgr, repo, log, executor.ExecutorConfig{})
sched := scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, log, scheduler.DefaultSchedulerConfig())
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, wrappedMgr)
svc.executor = exec
svc.scheduler = sched
// Re-load session for the call
session, err = repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to reload session: %v", err)
}
err = svc.ensureSessionRunning(ctx, "session1", session)
if err != nil {
t.Fatalf("ensureSessionRunning failed: %v", err)
}
if !startAgentProcessCalled {
t.Fatal("expected StartAgentProcess to be called (prepared workspace path)")
}
// Verify the session transitioned through STARTING
updated, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to reload session: %v", err)
}
if updated.State != models.TaskSessionStateWaitingForInput {
t.Fatalf("expected session state %q, got %q", models.TaskSessionStateWaitingForInput, updated.State)
}
}
func TestEnsureSessionRunning_WaitingForInputUsesResumePath(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
// Session in WAITING_FOR_INPUT without executor running record → resume path fails gracefully
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
agentMgr := &mockAgentManager{isAgentRunning: false}
log := testLogger()
exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
svc.executor = exec
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
// Should fail because there is no executor running record (resume path)
err = svc.ensureSessionRunning(ctx, "session1", session)
if err == nil {
t.Fatal("expected error for WAITING_FOR_INPUT session without executor record")
}
// Verify it took the resume path (error mentions "not resumable")
if !strings.Contains(err.Error(), "not resumable") {
t.Fatalf("expected 'not resumable' error from resume path, got: %v", err)
}
}
func TestEnsureSessionRunning_CreatedWithoutExecutionUsesResumePath(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
// Session in CREATED state WITHOUT AgentExecutionID → resume path (not prepared workspace)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCreated)
agentMgr := &mockAgentManager{isAgentRunning: false}
log := testLogger()
exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{})
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
svc.executor = exec
session, err := repo.GetTaskSession(ctx, "session1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
// AgentExecutionID is empty → should NOT take prepared workspace path
// Should fail with "not resumable" because no executor running record
err = svc.ensureSessionRunning(ctx, "session1", session)
if err == nil {
t.Fatal("expected error for CREATED session without executor record")
}
if !strings.Contains(err.Error(), "not resumable") {
t.Fatalf("expected 'not resumable' error from resume path, got: %v", err)
}
}
// --- canRestoreWorkspace ---
func TestCanRestoreWorkspace(t *testing.T) {
tests := []struct {
name string
resp *dto.TaskSessionStatusResponse
want bool
}{
{
name: "nil response",
resp: nil,
want: false,
},
{
name: "nil worktree path",
resp: &dto.TaskSessionStatusResponse{},
want: false,
},
{
name: "empty worktree path",
resp: &dto.TaskSessionStatusResponse{WorktreePath: strPtr("")},
want: false,
},
{
name: "valid worktree path",
resp: &dto.TaskSessionStatusResponse{WorktreePath: strPtr("/tmp/worktrees/session1")},
want: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := canRestoreWorkspace(tc.resp); got != tc.want {
t.Errorf("canRestoreWorkspace() = %v, want %v", got, tc.want)
}
})
}
}
// --- GetTaskSessionStatus: NeedsWorkspaceRestore ---
func TestGetTaskSessionStatus_NeedsWorkspaceRestore_TerminalWithWorktree(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted)
// Add worktree to session
now := time.Now().UTC()
if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{
ID: "wt1",
SessionID: "session1",
WorktreeID: "wid1",
RepositoryID: "repo1",
WorktreePath: "/tmp/worktrees/session1",
WorktreeBranch: "feature/test",
CreatedAt: now,
}); err != nil {
t.Fatalf("failed to create worktree: %v", err)
}
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1")
if err != nil {
t.Fatalf("GetTaskSessionStatus returned error: %v", err)
}
if !resp.NeedsWorkspaceRestore {
t.Fatal("expected NeedsWorkspaceRestore=true for terminal session with worktree")
}
if resp.NeedsResume {
t.Fatal("expected NeedsResume=false for terminal session")
}
}
func TestGetTaskSessionStatus_NeedsWorkspaceRestore_TerminalWithoutWorktree(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted)
taskRepo := newMockTaskRepo()
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1")
if err != nil {
t.Fatalf("GetTaskSessionStatus returned error: %v", err)
}
if resp.NeedsWorkspaceRestore {
t.Fatal("expected NeedsWorkspaceRestore=false for terminal session without worktree")
}
}
// sessionUpdatingAgentManager wraps mockAgentManager to update session state
// when StartAgentProcess is called, simulating the agent initialization flow.
type sessionUpdatingAgentManager struct {
*mockAgentManager
repo *sqliterepo.Repository
sessionID string
taskID string
onStartCalled *bool
}
func (m *sessionUpdatingAgentManager) StartAgentProcess(_ context.Context, _ string) error {
*m.onStartCalled = true
// Simulate the agent starting by transitioning session to WAITING_FOR_INPUT
ctx := context.Background()
sess, err := m.repo.GetTaskSession(ctx, m.sessionID)
if err == nil && sess != nil {
sess.State = models.TaskSessionStateWaitingForInput
sess.UpdatedAt = time.Now().UTC()
_ = m.repo.UpdateTaskSession(ctx, sess)
}
return nil
}