package backendapp import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/agent/executor" client "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/agentctl/server/process" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/quickterminal" quickterminalrepo "github.com/kandev/kandev/internal/quickterminal/repository" storagepkg "github.com/kandev/kandev/internal/system/storage" storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces" taskdto "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository" sqlitetaskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" userservice "github.com/kandev/kandev/internal/user/service" userstore "github.com/kandev/kandev/internal/user/store" "github.com/kandev/kandev/internal/webapp" workflowrepo "github.com/kandev/kandev/internal/workflow/repository" workflowservice "github.com/kandev/kandev/internal/workflow/service" "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" ) func TestRegisterTaskRoutesWiresProductionWorkspaceRestorer(t *testing.T) { gin.SetMode(gin.TestMode) harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil || len(workspaces) == 0 { t.Fatalf("ListWorkspaces: workspaces=%d err=%v", len(workspaces), err) } workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil || len(workflows) == 0 { t.Fatalf("ListWorkflows: workflows=%d err=%v", len(workflows), err) } steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil || len(steps) == 0 { t.Fatalf("ListStepsByWorkflow: steps=%d err=%v", len(steps), err) } taskResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Production unarchive wiring", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } if err := harness.taskRepo.ArchiveTask(ctx, task.ID); err != nil { t.Fatalf("ArchiveTask: %v", err) } log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) router := gin.New() gateway := gateways.NewGateway(log) settings, store := newStorageMaintenanceStores(t) composition := storageComposition{workspaceRestorer: &workspaceQuarantineController{ settings: settings, factory: func(storagepkg.StorageMaintenanceSettings) *storageworkspaces.Provider { return storageworkspaces.New(storageworkspaces.Config{Store: store}) }, }} handoff := taskservice.NewHandoffService( harness.taskRepo, harness.taskRepo, taskservice.NewDocumentService(harness.taskRepo, log), nil, nil, log, ) registerTaskRoutes(routeParams{ router: router, gateway: gateway, taskSvc: harness.taskSvc, taskRepo: harness.taskRepo, services: &Services{Workflow: harness.workflowSvc}, workspaceRestorer: composition.workspaceRestorer, log: log, }, taskservice.NewPlanService(harness.taskRepo, nil, log), handoff) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/unarchive", nil) router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("unarchive status = %d, body=%s", recorder.Code, recorder.Body.String()) } var response struct { WorkspaceRecovery []storageworkspaces.WorkspaceRecovery `json:"workspace_recovery"` } if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { t.Fatalf("decode response: %v", err) } if len(response.WorkspaceRecovery) != 1 || response.WorkspaceRecovery[0].Status != "not_found" { t.Fatalf("workspace recovery = %#v", response.WorkspaceRecovery) } } func decodePayload(t *testing.T, raw json.RawMessage) map[string]interface{} { t.Helper() var payload map[string]interface{} if err := json.Unmarshal(raw, &payload); err != nil { t.Fatalf("failed to decode payload: %v", err) } return payload } func TestBuildGitStatusNotificationIncludesAncestryEvidence(t *testing.T) { msg := buildGitStatusNotification("session-1", "web", client.GitStatusResult{ Branch: "feature/rewrite", RemoteBranch: "origin/feature/rewrite", HeadCommit: "local-head", BaseCommit: "base-head", Ahead: 5, Behind: 1, RemoteAhead: 2, RemoteBehind: 3, RemoteHeadCommit: "remote-head", }) if msg == nil { t.Fatal("buildGitStatusNotification returned nil") } payload := decodePayload(t, msg.Payload) status, ok := payload["status"].(map[string]interface{}) if !ok { t.Fatalf("status payload = %#v, want an object", payload["status"]) } for key, want := range map[string]interface{}{ "head_commit": "local-head", "base_commit": "base-head", "remote_ahead": float64(2), "remote_behind": float64(3), "remote_head_commit": "remote-head", } { if got := status[key]; got != want { t.Errorf("status[%q] = %#v, want %#v", key, got, want) } } } // TestAppendSessionStateMessage_IncludesTaskEnvironmentID asserts the snapshot // the WS hub sends on `session.subscribe` carries `task_environment_id`. // // Why this matters: PR #758 routes shell terminals by environment, and the // frontend reads `environmentIdBySessionId` from `session.state_changed` // payloads to populate that map. If the subscribe snapshot omits it, // late-subscribing clients (page reload, task switch, WS reconnect) leave // `environmentId=null` for the active session and the terminal panel hangs // on "Connecting terminal..." forever. func TestAppendSessionStateMessage_IncludesTaskEnvironmentID(t *testing.T) { session := &models.TaskSession{ ID: "sess-1", TaskID: "task-1", State: models.TaskSessionStateRunning, TaskEnvironmentID: "env-42", } msgs := appendSessionStateMessage(session.ID, session, nil) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } if msgs[0].Action != ws.ActionSessionStateChanged { t.Fatalf("expected action %q, got %q", ws.ActionSessionStateChanged, msgs[0].Action) } payload := decodePayload(t, msgs[0].Payload) got, present := payload["task_environment_id"] if !present { t.Fatalf("payload missing task_environment_id key — frontend env map will not be seeded") } if got != "env-42" { t.Fatalf("expected task_environment_id=env-42, got %v", got) } } type fakeCancellationPendingProvider struct { pending bool revision uint64 } func (p fakeCancellationPendingProvider) CancellationPending(string) bool { return p.pending } func (p fakeCancellationPendingProvider) CancellationPendingSnapshot(string) (bool, uint64) { return p.pending, p.revision } func TestAppendSessionStateMessage_IncludesCancellationPending(t *testing.T) { session := &models.TaskSession{ID: "sess-cancel", TaskID: "task-1"} msgs := appendSessionStateMessageWithCancellation( session.ID, session, fakeCancellationPendingProvider{pending: true, revision: 7}, nil, ) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } payload := decodePayload(t, msgs[0].Payload) if got, ok := payload["cancellation_pending"]; !ok || got != true { t.Fatalf("cancellation_pending = %#v, want explicit true", payload["cancellation_pending"]) } if got, ok := payload["cancellation_revision"]; !ok || got != float64(7) { t.Fatalf("cancellation_revision = %#v, want 7", payload["cancellation_revision"]) } } func TestAppendAgentctlStatusMessage_IncludesWorkspacePathForReload(t *testing.T) { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "console", OutputPath: "stdout", }) if err != nil { t.Fatalf("NewLogger: %v", err) } mgr := lifecycle.NewManager(nil, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackDeny, t.TempDir(), log) workspacePath := filepath.Join(t.TempDir(), "scratch") if err := mgr.ExecutionStoreForTesting().Add(&lifecycle.AgentExecution{ ID: "exec-1", TaskID: "task-1", SessionID: "sess-1", TaskEnvironmentID: "env-1", WorkspacePath: workspacePath, }); err != nil { t.Fatalf("add execution: %v", err) } msgs := appendAgentctlStatusMessage(context.Background(), mgr, "sess-1", nil, log) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } if msgs[0].Action != ws.ActionSessionAgentctlStarting { t.Fatalf("expected action %q, got %q", ws.ActionSessionAgentctlStarting, msgs[0].Action) } payload := decodePayload(t, msgs[0].Payload) got, present := payload["worktree_path"] if !present { t.Fatalf("payload missing worktree_path — reload cannot hydrate repo-less scratch workspace path") } if got != workspacePath { t.Fatalf("expected worktree_path=%q, got %v", workspacePath, got) } } func TestAppendAgentctlStatusMessage_ReplaysReadyForPreparedWorkspace(t *testing.T) { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "console", OutputPath: "stdout", }) if err != nil { t.Fatalf("NewLogger: %v", err) } mgr := lifecycle.NewManager(nil, nil, nil, nil, nil, nil, lifecycle.ExecutorFallbackDeny, t.TempDir(), log) execution := &lifecycle.AgentExecution{ ID: "exec-prepared", TaskID: "task-1", SessionID: "sess-prepared", TaskEnvironmentID: "env-1", WorkspacePath: filepath.Join(t.TempDir(), "scratch"), } execution.MarkAgentctlReady() if err := mgr.ExecutionStoreForTesting().Add(execution); err != nil { t.Fatalf("add execution: %v", err) } msgs := appendAgentctlStatusMessage(context.Background(), mgr, execution.SessionID, nil, log) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } if msgs[0].Action != ws.ActionSessionAgentctlReady { t.Fatalf("expected action %q, got %q", ws.ActionSessionAgentctlReady, msgs[0].Action) } if execution.GetWorkspaceStream() != nil { t.Fatal("prepared workspace should reproduce without an attached workspace stream") } } func TestResolveRepositoryIDForSubpathMatchesSanitizedRepositoryName(t *testing.T) { ctx := context.Background() dbPath := filepath.Join(t.TempDir(), "backendapp-session.db") dbConn, err := db.OpenSQLite(dbPath) if err != nil { t.Fatalf("open sqlite: %v", err) } sqlxDB := sqlx.NewDb(dbConn, "sqlite3") repo, err := sqlitetaskrepo.NewWithDB(sqlxDB, sqlxDB, nil) if err != nil { t.Fatalf("new task repo: %v", err) } t.Cleanup(func() { _ = sqlxDB.Close() }) log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "console", OutputPath: "stdout", }) if err != nil { t.Fatalf("NewLogger: %v", err) } now := time.Now().UTC() if _, err := sqlxDB.Exec(sqlxDB.Rebind(` INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?) `), "workspace-1", "Workspace", now, now); err != nil { t.Fatalf("seed workspace: %v", err) } if _, err := sqlxDB.Exec(sqlxDB.Rebind(` INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?) `), "task-1", "workspace-1", "Test task", now, now); err != nil { t.Fatalf("seed task: %v", err) } if _, err := sqlxDB.Exec(sqlxDB.Rebind(` INSERT INTO task_sessions (id, task_id, started_at, updated_at) VALUES (?, ?, ?, ?) `), "session-1", "task-1", now, now); err != nil { t.Fatalf("seed session: %v", err) } if err := repo.CreateRepository(ctx, &models.Repository{ ID: "repo-1", WorkspaceID: "workspace-1", Name: "kdlbs/kandev", SourceType: "remote", Provider: "github", ProviderOwner: "kdlbs", ProviderName: kandevName, DefaultBranch: "main", WorktreeBranchPrefix: "feature/", WorktreeBranchTemplate: "feature/{title}-{suffix}", PullBeforeWorktree: true, }); err != nil { t.Fatalf("CreateRepository: %v", err) } if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ ID: "task-repository-1", TaskID: "task-1", RepositoryID: "repo-1", Position: 0, }); err != nil { t.Fatalf("CreateTaskRepository: %v", err) } if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ ID: "env-1", TaskID: "task-1", ExecutorType: "worktree", WorkspacePath: "/tmp", Status: models.TaskEnvironmentStatusReady, }); err != nil { t.Fatalf("CreateTaskEnvironment: %v", err) } if _, err := sqlxDB.Exec(sqlxDB.Rebind( `UPDATE task_sessions SET task_environment_id = ? WHERE id = ?`), "env-1", "session-1"); err != nil { t.Fatalf("link session to env: %v", err) } if err := repo.CreateTaskEnvironmentRepo(ctx, &models.TaskEnvironmentRepo{ ID: "session-worktree-1", TaskEnvironmentID: "env-1", RepositoryID: "repo-1", WorktreeID: "worktree-1", WorktreePath: "/tmp/worktree", WorktreeBranch: "feature/test", BranchSlug: "test", Position: 0, }); err != nil { t.Fatalf("CreateTaskSessionWorktree: %v", err) } got := resolveRepositoryIDForSessionSubpath(ctx, repo, "session-1", "kdlbs-kandev", log) if got != "repo-1" { t.Fatalf("resolveRepositoryIDForSessionSubpath = %q, want repo-1", got) } got = resolveRepositoryIDForSubpath(ctx, repo, "task-1", "kdlbs-kandev", log) if got != "repo-1" { t.Fatalf("resolveRepositoryIDForSubpath = %q, want repo-1", got) } } func TestStopLifecycleManagerAllowsAgentctlInstanceCleanupWindow(t *testing.T) { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "console", OutputPath: "stdout", }) if err != nil { t.Fatalf("NewLogger: %v", err) } stopper := &shutdownDeadlineExecutor{} execRegistry := lifecycle.NewExecutorRegistry(log) execRegistry.Register(stopper) mgr := lifecycle.NewManager(nil, nil, execRegistry, nil, nil, nil, lifecycle.ExecutorFallbackDeny, t.TempDir(), log) if err := mgr.ExecutionStoreForTesting().Add(&lifecycle.AgentExecution{ ID: "exec-1", TaskID: "task-1", SessionID: "sess-1", RuntimeName: executor.NameStandalone, }); err != nil { t.Fatalf("add execution: %v", err) } startedAt := time.Now() _ = stopLifecycleManager(mgr, log) if stopper.deadline.IsZero() { t.Fatal("StopInstance was not called with a deadline") } if got := stopper.deadline.Sub(startedAt); got < 15*time.Second { t.Fatalf("agent shutdown timeout = %v, want at least 15s for agentctl instance cleanup", got) } } func TestBootInitialStateIncludesFeatureFlags(t *testing.T) { state := bootInitialState( context.Background(), nil, routeParams{features: config.FeaturesConfig{Office: true}}, webapp.ClassifyRoute("/"), ) raw, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal state: %v", err) } var decoded struct { Features config.FeaturesConfig `json:"features"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal state: %v", err) } if !decoded.Features.Office { t.Fatal("features.office should hydrate true from the backend boot payload") } } type shutdownDeadlineExecutor struct { deadline time.Time } func (s *shutdownDeadlineExecutor) Name() executor.Name { return executor.NameStandalone } func (s *shutdownDeadlineExecutor) HealthCheck(context.Context) error { return nil } func (s *shutdownDeadlineExecutor) CreateInstance( context.Context, *lifecycle.ExecutorCreateRequest, ) (*lifecycle.ExecutorInstance, error) { return nil, nil } func (s *shutdownDeadlineExecutor) StopInstance( ctx context.Context, _ *lifecycle.ExecutorInstance, _ bool, ) error { if deadline, ok := ctx.Deadline(); ok { s.deadline = deadline } return nil } func (s *shutdownDeadlineExecutor) RecoverInstances(context.Context) ([]*lifecycle.ExecutorInstance, error) { return nil, nil } func (s *shutdownDeadlineExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } func (s *shutdownDeadlineExecutor) RequiresCloneURL() bool { return false } func (s *shutdownDeadlineExecutor) ShouldApplyPreferredShell() bool { return true } func (s *shutdownDeadlineExecutor) IsAlwaysResumable() bool { return false } func TestBootInitialStateHomeIncludesKanbanFirstPaintState(t *testing.T) { harness := newBootStateTestHarness(t) taskSvc, workflowSvc := harness.taskSvc, harness.workflowSvc ctx := context.Background() workspaces, err := taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } if len(workspaces) == 0 { t.Fatal("expected seeded default workspace") } workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil { t.Fatalf("ListWorkflows: %v", err) } if len(workflows) == 0 { t.Fatal("expected seeded default workflow") } steps, err := workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil { t.Fatalf("ListStepsByWorkflow: %v", err) } if len(steps) == 0 { t.Fatal("expected seeded default workflow step") } taskResult, err := taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Boot home task", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } now := time.Now().UTC() if err := harness.taskRepo.CreateTaskSession(ctx, &models.TaskSession{ ID: "boot-session-waiting", TaskID: task.ID, State: models.TaskSessionStateWaitingForInput, IsPrimary: true, StartedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTaskSession: %v", err) } if err := harness.taskRepo.CreateTurn(ctx, &models.Turn{ ID: "boot-turn-waiting", TaskID: task.ID, TaskSessionID: "boot-session-waiting", StartedAt: now, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTurn: %v", err) } if err := harness.taskRepo.CreateMessage(ctx, &models.Message{ ID: "boot-clarification", TaskID: task.ID, TaskSessionID: "boot-session-waiting", TurnID: "boot-turn-waiting", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeClarificationRequest, Content: "Need input", Metadata: map[string]interface{}{"status": "pending"}, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateMessage: %v", err) } if err := harness.taskRepo.CreateTaskSession(ctx, &models.TaskSession{ ID: "boot-session-secondary", TaskID: task.ID, State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTaskSession secondary: %v", err) } if err := harness.taskRepo.CreateTurn(ctx, &models.Turn{ ID: "boot-turn-secondary", TaskID: task.ID, TaskSessionID: "boot-session-secondary", StartedAt: now, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTurn secondary: %v", err) } if err := harness.taskRepo.CreateMessage(ctx, &models.Message{ ID: "boot-permission", TaskID: task.ID, TaskSessionID: "boot-session-secondary", TurnID: "boot-turn-secondary", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypePermissionRequest, Content: "Allow?", Metadata: map[string]interface{}{"status": "pending"}, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateMessage secondary: %v", err) } state := bootInitialState( ctx, nil, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, webapp.ClassifyRoute("/"), ) raw, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal state: %v", err) } var decoded struct { Workspaces struct { Items []taskdto.WorkspaceDTO `json:"items"` ActiveID string `json:"activeId"` } `json:"workspaces"` Workflows struct { Items []struct { ID string `json:"id"` WorkspaceID string `json:"workspaceId"` Name string `json:"name"` } `json:"items"` ActiveID string `json:"activeId"` } `json:"workflows"` KanbanMulti struct { Snapshots map[string]struct { WorkflowID string `json:"workflowId"` WorkflowName string `json:"workflowName"` Steps []struct { ID string `json:"id"` Title string `json:"title"` } `json:"steps"` Tasks []struct { ID string `json:"id"` WorkflowStepID string `json:"workflowStepId"` PrimarySessionState *string `json:"primarySessionState"` PrimarySessionPendingAction *string `json:"primarySessionPendingAction"` TaskPendingAction *string `json:"taskPendingAction"` } `json:"tasks"` } `json:"snapshots"` IsLoading bool `json:"isLoading"` } `json:"kanbanMulti"` Kanban struct { WorkflowID string `json:"workflowId"` Steps []struct { ID string `json:"id"` Title string `json:"title"` } `json:"steps"` Tasks []struct { ID string `json:"id"` WorkflowStepID string `json:"workflowStepId"` PrimarySessionState *string `json:"primarySessionState"` PrimarySessionPendingAction *string `json:"primarySessionPendingAction"` TaskPendingAction *string `json:"taskPendingAction"` } `json:"tasks"` IsLoading bool `json:"isLoading"` } `json:"kanban"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal state: %v", err) } if decoded.Workspaces.ActiveID != workspaces[0].ID { t.Fatalf("active workspace = %q, want %q", decoded.Workspaces.ActiveID, workspaces[0].ID) } if len(decoded.Workflows.Items) == 0 || decoded.Workflows.ActiveID == "" { t.Fatalf("expected workflows to hydrate first paint, got %+v", decoded.Workflows) } if _, ok := decoded.KanbanMulti.Snapshots[workflows[0].ID]; !ok { t.Fatalf("expected snapshot for workflow %q in boot payload", workflows[0].ID) } snapshotTask := decoded.KanbanMulti.Snapshots[workflows[0].ID].Tasks[0] if snapshotTask.PrimarySessionState == nil || *snapshotTask.PrimarySessionState != "WAITING_FOR_INPUT" { t.Fatalf("snapshot primarySessionState = %#v, want WAITING_FOR_INPUT", snapshotTask.PrimarySessionState) } if snapshotTask.PrimarySessionPendingAction == nil || *snapshotTask.PrimarySessionPendingAction != "clarification" { t.Fatalf( "snapshot primarySessionPendingAction = %#v, want clarification", snapshotTask.PrimarySessionPendingAction, ) } if snapshotTask.TaskPendingAction == nil || *snapshotTask.TaskPendingAction != "permission" { t.Fatalf("snapshot taskPendingAction = %#v, want permission", snapshotTask.TaskPendingAction) } if decoded.Kanban.WorkflowID != workflows[0].ID { t.Fatalf("active kanban workflow = %q, want %q", decoded.Kanban.WorkflowID, workflows[0].ID) } activeTask := decoded.Kanban.Tasks[0] if activeTask.PrimarySessionPendingAction == nil || *activeTask.PrimarySessionPendingAction != "clarification" { t.Fatalf( "active primarySessionPendingAction = %#v, want clarification", activeTask.PrimarySessionPendingAction, ) } if activeTask.TaskPendingAction == nil || *activeTask.TaskPendingAction != "permission" { t.Fatalf("active taskPendingAction = %#v, want permission", activeTask.TaskPendingAction) } if decoded.KanbanMulti.IsLoading || decoded.Kanban.IsLoading { t.Fatal("boot payload should mark kanban data loaded") } } func TestBootTaskPendingActionExcludesStartingAndTerminalSessions(t *testing.T) { sessions := []*models.TaskSession{ {ID: startingStatus, State: models.TaskSessionStateStarting}, {ID: "completed", State: models.TaskSessionStateCompleted}, {ID: "failed", State: models.TaskSessionStateFailed}, } actions := map[string]models.TaskPendingAction{ startingStatus: models.TaskPendingActionPermission, "completed": models.TaskPendingActionPermission, "failed": models.TaskPendingActionClarification, } if got := bootTaskPendingActionPtr(sessions, actions); got != nil { t.Fatalf("bootTaskPendingActionPtr() = %q, want nil for stale sessions", *got) } } func TestBootInitialStateHomePreservesSavedAllWorkflowsFilter(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } if len(workspaces) == 0 { t.Fatal("expected seeded default workspace") } workflowB, err := harness.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ WorkspaceID: workspaces[0].ID, Name: "Workflow B", }) if err != nil { t.Fatalf("CreateWorkflow: %v", err) } allWorkflows := "" repositoryIDs := []string{} if _, err := harness.userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{ WorkspaceID: &workspaces[0].ID, WorkflowFilterID: &allWorkflows, RepositoryIDs: &repositoryIDs, }); err != nil { t.Fatalf("UpdateUserSettings: %v", err) } state := bootInitialState( ctx, nil, routeParams{ taskSvc: harness.taskSvc, userCtrl: harness.userCtrl, services: &Services{ Workflow: harness.workflowSvc, }, }, webapp.ClassifyRoute("/"), ) raw, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal state: %v", err) } var decoded struct { Workflows struct { ActiveID *string `json:"activeId"` } `json:"workflows"` UserSettings struct { WorkflowID *string `json:"workflowId"` } `json:"userSettings"` KanbanMulti struct { Snapshots map[string]json.RawMessage `json:"snapshots"` } `json:"kanbanMulti"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal state: %v", err) } if decoded.Workflows.ActiveID != nil { t.Fatalf("active workflow = %q, want nil for All Workflows", *decoded.Workflows.ActiveID) } if decoded.UserSettings.WorkflowID != nil { t.Fatalf("user settings workflow = %q, want nil for All Workflows", *decoded.UserSettings.WorkflowID) } if _, ok := decoded.KanbanMulti.Snapshots[workflowB.ID]; !ok { t.Fatalf("expected boot snapshots to include second workflow %q", workflowB.ID) } } func TestBootRouteDataTaskDetailIncludesTaskPageData(t *testing.T) { taskSvc, workflowSvc := newBootStateTestServices(t) ctx := context.Background() workspaces, err := taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil { t.Fatalf("ListWorkflows: %v", err) } steps, err := workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil { t.Fatalf("ListStepsByWorkflow: %v", err) } taskResult, err := taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Boot detail task", Description: "Should be present before React mounts", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } payload := webapp.NewBootPayload( webapp.ClassifyRoute("/t/"+task.ID), webapp.RuntimeConfig{APIPrefix: "/api/v1", WebSocketPath: "/ws"}, bootInitialState(ctx, nil, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, webapp.ClassifyRoute("/t/"+task.ID)), ) payload.RouteData = bootRouteData(ctx, nil, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, webapp.ClassifyRoute("/t/"+task.ID)) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { RouteData struct { TaskDetail struct { Task struct { ID string `json:"id"` Title string `json:"title"` } `json:"task"` SessionID *string `json:"sessionId"` InitialState map[string]interface{} `json:"initialState"` } `json:"taskDetail"` } `json:"routeData"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if decoded.RouteData.TaskDetail.Task.ID != task.ID { t.Fatalf("route task id = %q, want %q", decoded.RouteData.TaskDetail.Task.ID, task.ID) } if decoded.RouteData.TaskDetail.Task.Title != "Boot detail task" { t.Fatalf("route task title = %q", decoded.RouteData.TaskDetail.Task.Title) } if decoded.RouteData.TaskDetail.SessionID != nil { t.Fatalf("new task should not have a boot session id, got %q", *decoded.RouteData.TaskDetail.SessionID) } if _, ok := decoded.RouteData.TaskDetail.InitialState["kanban"]; !ok { t.Fatal("task detail route data should include initialState.kanban") } } func TestBootPayloadMissingTaskFallsBackToHomeKanbanState(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil || len(workspaces) == 0 { t.Fatalf("ListWorkspaces: count=%d err=%v", len(workspaces), err) } workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil || len(workflows) == 0 { t.Fatalf("ListWorkflows: count=%d err=%v", len(workflows), err) } steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil || len(steps) == 0 { t.Fatalf("ListStepsByWorkflow: count=%d err=%v", len(steps), err) } taskResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Visible sibling task", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } request := httptest.NewRequest( http.MethodGet, "/t/missing-task", nil, ) request.AddCookie(&http.Cookie{Name: activeWorkspaceCookie, Value: workspaces[0].ID}) route := webapp.ClassifyRoute("/t/missing-task") payload := bootPayload(ctx, request, routeParams{ taskSvc: harness.taskSvc, userCtrl: harness.userCtrl, services: &Services{Workflow: harness.workflowSvc}, }, route) if payload.RouteData != nil { t.Fatalf("missing task route data = %#v, want nil", payload.RouteData) } workspacesState, ok := payload.InitialState["workspaces"].(map[string]any) if !ok { t.Fatalf("missing fallback workspaces state: %#v", payload.InitialState["workspaces"]) } if got := workspacesState["activeId"]; got != workspaces[0].ID { t.Fatalf("fallback active workspace = %v, want %q", got, workspaces[0].ID) } if _, ok := payload.InitialState["workflows"].(map[string]any); !ok { t.Fatalf("missing fallback workflows state: %#v", payload.InitialState["workflows"]) } if _, ok := payload.InitialState["repositories"].(map[string]any); !ok { t.Fatalf("missing fallback repositories state: %#v", payload.InitialState["repositories"]) } kanbanMulti, ok := payload.InitialState["kanbanMulti"].(map[string]any) if !ok { t.Fatalf("missing fallback kanban state: %#v", payload.InitialState["kanbanMulti"]) } snapshots, ok := kanbanMulti["snapshots"].(map[string]any) if !ok { t.Fatalf("fallback kanban snapshots = %#v", kanbanMulti["snapshots"]) } snapshot, ok := snapshots[workflows[0].ID].(map[string]any) if !ok { t.Fatalf("missing fallback workflow snapshot %q: %#v", workflows[0].ID, snapshots) } tasks, ok := snapshot["tasks"].([]map[string]any) if !ok { // JSON-safe boot state is assembled with []map[string]any, so this // assertion protects the sidebar's task source without depending on // JSON round-tripping in the test. t.Fatalf("fallback workflow tasks = %#v", snapshot["tasks"]) } if len(tasks) != 1 || tasks[0]["id"] != task.ID { t.Fatalf("fallback workflow tasks = %#v, want task %q", tasks, task.ID) } } func TestBootPayloadAnonymousMissingTaskDoesNotLoadHomeState(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() request := httptest.NewRequest(http.MethodGet, "/t/missing-task", nil) route := webapp.ClassifyRoute("/t/missing-task") payload := bootPayload(ctx, request, routeParams{ taskSvc: harness.taskSvc, userCtrl: harness.userCtrl, authSvc: newSSOTestAuthService(t), services: &Services{Workflow: harness.workflowSvc}, }, route) if payload.RouteData != nil { t.Fatalf("anonymous missing task route data = %#v, want nil", payload.RouteData) } if _, ok := payload.InitialState["auth"]; !ok { t.Fatal("anonymous auth-enabled bootstrap should include auth state") } for _, key := range []string{"workspaces", "workflows", "repositories", "kanbanMulti"} { if _, ok := payload.InitialState[key]; ok { t.Fatalf("anonymous auth-enabled bootstrap should not include %s state", key) } } } func TestBootPayloadValidTaskKeepsRouteSpecificState(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil || len(workspaces) == 0 { t.Fatalf("ListWorkspaces: count=%d err=%v", len(workspaces), err) } workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil || len(workflows) == 0 { t.Fatalf("ListWorkflows: count=%d err=%v", len(workflows), err) } steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil || len(steps) == 0 { t.Fatalf("ListStepsByWorkflow: count=%d err=%v", len(steps), err) } taskResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Valid detail task", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } request := httptest.NewRequest(http.MethodGet, "/t/"+task.ID, nil) route := webapp.ClassifyRoute("/t/" + task.ID) payload := bootPayload(ctx, request, routeParams{ taskSvc: harness.taskSvc, userCtrl: harness.userCtrl, services: &Services{Workflow: harness.workflowSvc}, }, route) if payload.RouteData == nil { t.Fatal("valid task route data is nil") } detail, ok := payload.RouteData["taskDetail"].(map[string]any) if !ok { t.Fatalf("task detail route data = %#v", payload.RouteData["taskDetail"]) } detailTask, ok := detail["task"].(taskdto.TaskDTO) if !ok { t.Fatalf("task detail task = %#v", detail["task"]) } if detailTask.ID != task.ID { t.Fatalf("task detail id = %q, want %q", detailTask.ID, task.ID) } if _, ok := payload.InitialState["workspaces"]; ok { t.Fatal("valid task route should keep the lean initial state without home workspace data") } } func TestBootTaskDetailMessagesProjectShellOutput(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil || len(workspaces) == 0 { t.Fatalf("ListWorkspaces: %v", err) } taskResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, Title: "Shell output projection", IsEphemeral: true, }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) if err := harness.taskRepo.CreateTaskSession(ctx, &models.TaskSession{ ID: "shell-session", TaskID: task.ID, State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTaskSession: %v", err) } if err := harness.taskRepo.CreateTurn(ctx, &models.Turn{ ID: "shell-turn", TaskID: task.ID, TaskSessionID: "shell-session", StartedAt: now, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateTurn: %v", err) } if err := harness.taskRepo.CreateMessage(ctx, &models.Message{ ID: "shell-message", TaskID: task.ID, TaskSessionID: "shell-session", TurnID: "shell-turn", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeToolExecute, Content: "make test", Metadata: map[string]any{ "status": "completed", "normalized": map[string]any{ "kind": "shell_exec", "shell_exec": map[string]any{ "command": "make test", "output": map[string]any{"stdout": "boot-output-sentinel"}, }, }, }, CreatedAt: now, UpdatedAt: now, }); err != nil { t.Fatalf("CreateMessage: %v", err) } state := map[string]any{} builder := bootStateBuilder{p: routeParams{taskSvc: harness.taskSvc}} builder.addTaskDetailActiveTaskState(ctx, state, taskdto.FromTask(task), "shell-session") raw, err := json.Marshal(state) if err != nil { t.Fatalf("Marshal state: %v", err) } if strings.Contains(string(raw), "boot-output-sentinel") { t.Fatal("boot message payload leaked shell output") } if !strings.Contains(string(raw), `"stdout_bytes":20`) { t.Fatalf("boot message payload missing shell output summary: %s", raw) } } func TestBootRouteDataTaskDetailIncludesPersistedSessionModels(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil { t.Fatalf("ListWorkflows: %v", err) } steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil { t.Fatalf("ListStepsByWorkflow: %v", err) } taskResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Hydrated model selector", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } session := &models.TaskSession{ ID: "boot-model-session", TaskID: task.ID, IsPrimary: true, Metadata: map[string]interface{}{ models.SessionMetaKeyACPConfigBaseline: map[string]string{"effort": "medium"}, models.SessionMetaKeyACPModelState: lifecycle.SessionModelsSnapshot{ CurrentModelID: "gpt-5.6-sol", Models: []streams.SessionModelInfo{{ModelID: "gpt-5.6-sol", Name: "GPT-5.6-Sol"}}, ConfigOptions: []streams.ConfigOption{{ Type: "select", ID: "effort", Name: "Reasoning effort", Description: "Provider option help", CurrentValue: "high", Options: []streams.ConfigOptionValue{{Value: "high", Name: "High", Description: "Provider value help"}}, }}, }, }, } if err := harness.taskRepo.CreateTaskSession(ctx, session); err != nil { t.Fatalf("CreateTaskSession: %v", err) } routeData := bootRouteData(ctx, nil, routeParams{ taskSvc: harness.taskSvc, services: &Services{Workflow: harness.workflowSvc}, }, webapp.ClassifyRoute("/t/"+task.ID)) raw, err := json.Marshal(routeData) if err != nil { t.Fatalf("Marshal route data: %v", err) } var decoded struct { TaskDetail struct { InitialState struct { SessionModels struct { BySessionID map[string]struct { CurrentModelID string `json:"currentModelId"` Models []struct { ModelID string `json:"modelId"` } `json:"models"` ConfigOptions []struct { ID string `json:"id"` Description string `json:"description"` CurrentValue string `json:"currentValue"` Options []streams.ConfigOptionValue `json:"options"` } `json:"configOptions"` ConfigBaseline map[string]string `json:"configBaseline"` } `json:"bySessionId"` } `json:"sessionModels"` } `json:"initialState"` } `json:"taskDetail"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal route data: %v", err) } got := decoded.TaskDetail.InitialState.SessionModels.BySessionID[session.ID] if got.CurrentModelID != "gpt-5.6-sol" || len(got.Models) != 1 || got.Models[0].ModelID != "gpt-5.6-sol" { t.Fatalf("boot model state = %#v", got) } if len(got.ConfigOptions) != 1 || got.ConfigOptions[0].CurrentValue != "high" { t.Fatalf("boot config options = %#v", got.ConfigOptions) } if got.ConfigOptions[0].Description != "Provider option help" || got.ConfigOptions[0].Options[0].Description != "Provider value help" { t.Fatalf("provider descriptions missing from boot config: %#v", got.ConfigOptions[0]) } if got.ConfigBaseline["effort"] != "medium" { t.Fatalf("boot config baseline = %#v, want effort=medium", got.ConfigBaseline) } } func TestBootRouteDataTaskDetailIncludesPersistedTurns(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } workflows, err := harness.taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil { t.Fatalf("ListWorkflows: %v", err) } steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil { t.Fatalf("ListStepsByWorkflow: %v", err) } taskResult, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Hydrated turn snapshot", }) task := taskResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } now := time.Now().UTC() session := &models.TaskSession{ ID: "boot-turn-session", TaskID: task.ID, IsPrimary: true, } if err := harness.taskRepo.CreateTaskSession(ctx, session); err != nil { t.Fatalf("CreateTaskSession: %v", err) } turn := &models.Turn{ ID: "boot-persisted-turn", TaskID: task.ID, TaskSessionID: session.ID, StartedAt: now, CompletedAt: &now, CreatedAt: now, UpdatedAt: now, Metadata: map[string]interface{}{ models.TurnMetaKeyRuntimeConfigSnapshot: models.TurnRuntimeConfigSnapshot{ Model: "mock-fast", ConfigOptions: []models.TurnRuntimeConfigOption{{ ID: "effort", Name: "Effort", Value: "high", ValueName: "High", }}, ConfigBaseline: map[string]string{"effort": "medium"}, }, }, } if err := harness.taskRepo.CreateTurn(ctx, turn); err != nil { t.Fatalf("CreateTurn: %v", err) } routeData := bootRouteData(ctx, nil, routeParams{ taskSvc: harness.taskSvc, services: &Services{Workflow: harness.workflowSvc}, }, webapp.ClassifyRoute("/t/"+task.ID)) raw, err := json.Marshal(routeData) if err != nil { t.Fatalf("Marshal route data: %v", err) } var decoded struct { TaskDetail struct { InitialState struct { Turns struct { BySession map[string][]taskdto.TurnDTO `json:"bySession"` } `json:"turns"` } `json:"initialState"` } `json:"taskDetail"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal route data: %v", err) } got := decoded.TaskDetail.InitialState.Turns.BySession[session.ID] if len(got) != 1 || got[0].ID != turn.ID { t.Fatalf("boot turns = %#v, want turn %q", got, turn.ID) } snapshot, ok := models.LoadTurnRuntimeConfigSnapshot(got[0].Metadata) if !ok || len(snapshot.ConfigOptions) != 1 || snapshot.ConfigOptions[0].Value != "high" { t.Fatalf("boot turn snapshot = %#v, ok=%v", snapshot, ok) } } func TestTaskSessionModelsBootStateOmitsUnavailableBaseline(t *testing.T) { state := taskSessionModelsBootState(lifecycle.SessionModelsSnapshot{ CurrentModelID: "gpt-5.6-sol", }, nil) if _, ok := state["configBaseline"]; ok { t.Fatal("boot selector state must omit an unavailable baseline") } } func TestBootRouteDataTasksIncludesFirstPageRows(t *testing.T) { taskSvc, workflowSvc := newBootStateTestServices(t) ctx := context.Background() workspaces, err := taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } workflows, err := taskSvc.ListWorkflows(ctx, workspaces[0].ID, true) if err != nil { t.Fatalf("ListWorkflows: %v", err) } steps, err := workflowSvc.ListStepsByWorkflow(ctx, workflows[0].ID) if err != nil { t.Fatalf("ListStepsByWorkflow: %v", err) } createdResult, err := taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{ WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID, WorkflowStepID: steps[0].ID, Title: "Tasks table row", Description: "Visible on first paint", }) created := createdResult.Task if err != nil { t.Fatalf("CreateTask: %v", err) } route := webapp.ClassifyRoute("/tasks") payload := bootPayload(ctx, nil, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, route) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { InitialState struct { Workspaces struct { ActiveID string `json:"activeId"` } `json:"workspaces"` Workflows struct { ActiveID *string `json:"activeId"` Items []struct { ID string `json:"id"` } `json:"items"` } `json:"workflows"` } `json:"initialState"` RouteData struct { TasksPage struct { ActiveWorkspaceID string `json:"activeWorkspaceId"` Tasks []struct { ID string `json:"id"` Title string `json:"title"` } `json:"tasks"` Total int `json:"total"` } `json:"tasksPage"` } `json:"routeData"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if decoded.InitialState.Workspaces.ActiveID != workspaces[0].ID { t.Fatalf("active workspace = %q, want %q", decoded.InitialState.Workspaces.ActiveID, workspaces[0].ID) } if len(decoded.InitialState.Workflows.Items) == 0 { t.Fatal("tasks route should hydrate workflow items") } if decoded.RouteData.TasksPage.ActiveWorkspaceID != workspaces[0].ID { t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.TasksPage.ActiveWorkspaceID, workspaces[0].ID) } if decoded.RouteData.TasksPage.Total != 1 { t.Fatalf("tasks total = %d, want 1", decoded.RouteData.TasksPage.Total) } if len(decoded.RouteData.TasksPage.Tasks) != 1 || decoded.RouteData.TasksPage.Tasks[0].ID != created.ID { t.Fatalf("route tasks = %+v, want task %q", decoded.RouteData.TasksPage.Tasks, created.ID) } } func TestBootRouteDataTasksUsesActiveWorkspaceCookie(t *testing.T) { taskSvc, workflowSvc := newBootStateTestServices(t) ctx := context.Background() if _, err := taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{ Name: "Cookie Workspace", }); err != nil { t.Fatalf("CreateWorkspace: %v", err) } workspace := nonFallbackWorkspace(t, ctx, taskSvc) if _, err := taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ WorkspaceID: workspace.ID, Name: "Cookie Workflow", }); err != nil { t.Fatalf("CreateWorkflow: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2Ftasks", nil) req.AddCookie(&http.Cookie{Name: "kandev-active-workspace", Value: workspace.ID}) payload := bootPayload(ctx, req, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, webapp.ClassifyRoute("/tasks")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { InitialState struct { Workspaces struct { ActiveID string `json:"activeId"` } `json:"workspaces"` } `json:"initialState"` RouteData struct { TasksPage struct { ActiveWorkspaceID string `json:"activeWorkspaceId"` } `json:"tasksPage"` } `json:"routeData"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if decoded.InitialState.Workspaces.ActiveID != workspace.ID { t.Fatalf("initial active workspace = %q, want %q", decoded.InitialState.Workspaces.ActiveID, workspace.ID) } if decoded.RouteData.TasksPage.ActiveWorkspaceID != workspace.ID { t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.TasksPage.ActiveWorkspaceID, workspace.ID) } } func TestBootPayloadIncludesDebugRuntimeWhenDevMode(t *testing.T) { t.Parallel() payload := bootPayload(context.Background(), nil, routeParams{devMode: true}, webapp.ClassifyRoute("/")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { Runtime struct { Debug bool `json:"debug"` } `json:"runtime"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if !decoded.Runtime.Debug { t.Fatal("runtime.debug = false, want true when backend devMode is enabled") } } func TestBootPayloadCarriesConfiguredTitlePrefix(t *testing.T) { t.Parallel() payload := bootPayload( context.Background(), nil, routeParams{webTitlePrefix: "TEST"}, webapp.ClassifyRoute("/"), ) if got := payload.Runtime.TitlePrefix; got != "TEST" { t.Fatalf("runtime.titlePrefix = %q, want %q", got, "TEST") } } func TestBootPayloadOmitsUnsetTitlePrefix(t *testing.T) { t.Parallel() payload := bootPayload(context.Background(), nil, routeParams{}, webapp.ClassifyRoute("/")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } if strings.Contains(string(raw), "titlePrefix") { t.Fatalf("expected titlePrefix to be omitted, got: %s", raw) } } func TestBootPayloadRestoresQuickChatSessions(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() repo := harness.taskRepo quickTerminalRepo, err := quickterminalrepo.NewWithDB(harness.db, harness.db) if err != nil { t.Fatalf("quick terminal repository: %v", err) } quickTerminalSvc := quickterminal.NewService(quickTerminalRepo, nil, harness.taskSvc) if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-qc", Name: "Quick Chats"}); err != nil { t.Fatalf("CreateWorkspace: %v", err) } if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-other", Name: "Other"}); err != nil { t.Fatalf("CreateWorkspace(other): %v", err) } if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-qc", WorkspaceID: "ws-qc", Name: "Workflow"}); err != nil { t.Fatalf("CreateWorkflow: %v", err) } base := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-old", title: "Older Chat", updatedAt: base.Add(-3 * time.Hour), sessionUpdatedAt: base.Add(-90 * time.Minute), agentProfileID: "agent-old", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-new", title: "Newer Chat", updatedAt: base.Add(-time.Hour), sessionUpdatedAt: base.Add(-10 * time.Minute), agentProfileID: "agent-new", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-config", title: "Config", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-config", metadata: map[string]interface{}{"config_mode": true}, }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-archived", title: "Archived", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-archived", archived: true, }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-no-primary", title: "No Primary", updatedAt: base, agentProfileID: "agent-no-primary", withoutPrimary: true, }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-other-workspace", workspaceID: "ws-other", title: "Foreign", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-foreign", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-automation", title: "Automation", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-automation", origin: models.TaskOriginAutomationRun, }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-workflow", title: "Workflow Ephemeral", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-workflow", workflowID: "wf-qc", }) if _, err := quickTerminalSvc.Create(ctx, "ws-qc", "11111111-1111-4111-8111-111111111111"); err != nil { t.Fatalf("create quick terminal descriptor: %v", err) } req := httptest.NewRequest(http.MethodGet, "/?workspaceId=ws-qc", nil) payload := bootPayload(ctx, req, routeParams{ taskSvc: harness.taskSvc, services: &Services{Workflow: harness.workflowSvc}, userCtrl: harness.userCtrl, quickTerminalSvc: quickTerminalSvc, }, webapp.ClassifyRoute("/")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { InitialState struct { QuickChat struct { Sessions []struct { SessionID string `json:"sessionId"` WorkspaceID string `json:"workspaceId"` Name string `json:"name"` AgentProfileID string `json:"agentProfileId"` Kind string `json:"kind"` } `json:"sessions"` IsOpen bool `json:"isOpen"` ActiveSessionID *string `json:"activeSessionId"` TerminalTabs []struct { TabID string `json:"tabId"` Status string `json:"status"` Error string `json:"error"` Workspace string `json:"workspaceId"` } `json:"terminalTabs"` } `json:"quickChat"` TaskSessions struct { Items map[string]struct { TaskID string `json:"task_id"` } `json:"items"` } `json:"taskSessions"` } `json:"initialState"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } sessions := decoded.InitialState.QuickChat.Sessions if len(sessions) != 3 { t.Fatalf("quickChat sessions = %#v, want 3 restored sessions", sessions) } if got := sessions[0].SessionID; got != "task-config-session" { t.Fatalf("first restored session = %q, want newest task-config-session", got) } if sessions[0].Kind != "config" || sessions[1].Kind != "chat" || sessions[2].Kind != "chat" { t.Fatalf("restored session kinds = %#v, want config, chat, chat", sessions) } if sessions[0].WorkspaceID != "ws-qc" || sessions[0].Name != "Config" { t.Fatalf("config session identity = %#v, want workspace and task title preserved", sessions[0]) } if sessions[0].AgentProfileID != "agent-config" || sessions[1].AgentProfileID != "agent-new" || sessions[2].AgentProfileID != "agent-old" { t.Fatalf("agent profile IDs = %#v", sessions) } if got := decoded.InitialState.TaskSessions.Items["task-config-session"].TaskID; got != "task-config" { t.Fatalf("config chat task session task_id = %q, want task-config", got) } if len(decoded.InitialState.TaskSessions.Items) != 3 { t.Fatalf("taskSessions items = %#v, want only the 3 restored primary sessions", decoded.InitialState.TaskSessions.Items) } if decoded.InitialState.QuickChat.IsOpen { t.Fatal("quick chat should hydrate closed") } if decoded.InitialState.QuickChat.ActiveSessionID != nil { t.Fatalf("quick chat active session = %q, want nil", *decoded.InitialState.QuickChat.ActiveSessionID) } if len(decoded.InitialState.QuickChat.TerminalTabs) != 1 { t.Fatalf("quickChat terminal tabs = %#v, want one restored descriptor", decoded.InitialState.QuickChat.TerminalTabs) } tab := decoded.InitialState.QuickChat.TerminalTabs[0] if tab.TabID != "11111111-1111-4111-8111-111111111111" || tab.Workspace != "ws-qc" { t.Fatalf("restored terminal identity = %#v", tab) } if tab.Status != "exited" || tab.Error == "" { t.Fatalf("stale terminal lifecycle = %#v, want exited/unavailable", tab) } } func TestBootPayloadRestoresQuickChatsFromTaskRouteWorkspace(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() repo := harness.taskRepo for _, workspace := range []*models.Workspace{ {ID: "ws-active", Name: "Persisted Active"}, {ID: "ws-task", Name: "Task Workspace"}, } { if err := repo.CreateWorkspace(ctx, workspace); err != nil { t.Fatalf("CreateWorkspace(%s): %v", workspace.ID, err) } } if err := repo.CreateTask(ctx, &models.Task{ ID: "route-task", WorkspaceID: "ws-task", Title: "Route Task", State: v1.TaskStateTODO, Priority: "medium", }); err != nil { t.Fatalf("CreateTask(route-task): %v", err) } base := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-active-chat", workspaceID: "ws-active", title: "Wrong Workspace", updatedAt: base, sessionUpdatedAt: base, agentProfileID: "agent-active", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-route-first", workspaceID: "ws-task", title: "First Route Chat", updatedAt: base.Add(time.Minute), sessionUpdatedAt: base.Add(time.Minute), agentProfileID: "agent-first", }) seedBootQuickChatTask(t, repo, ctx, bootQuickChatFixture{ id: "task-route-second", workspaceID: "ws-task", title: "Second Route Chat", updatedAt: base.Add(2 * time.Minute), sessionUpdatedAt: base.Add(2 * time.Minute), agentProfileID: "agent-second", }) for _, routePath := range []string{"/t/route-task", "/office/tasks/route-task"} { t.Run(routePath, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, routePath, nil) req.AddCookie(&http.Cookie{Name: activeWorkspaceCookie, Value: "ws-active"}) payload := bootPayload(ctx, req, routeParams{ taskSvc: harness.taskSvc, services: &Services{Workflow: harness.workflowSvc}, userCtrl: harness.userCtrl, }, webapp.ClassifyRoute(routePath)) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { InitialState struct { QuickChat struct { Sessions []struct { SessionID string `json:"sessionId"` WorkspaceID string `json:"workspaceId"` } `json:"sessions"` } `json:"quickChat"` } `json:"initialState"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } sessions := decoded.InitialState.QuickChat.Sessions if len(sessions) != 2 { t.Fatalf("quickChat sessions = %#v, want 2 task-workspace sessions", sessions) } if sessions[0].SessionID != "task-route-second-session" || sessions[1].SessionID != "task-route-first-session" { t.Fatalf("quickChat sessions = %#v, want task-workspace activity order", sessions) } for _, session := range sessions { if session.WorkspaceID != "ws-task" { t.Fatalf("restored workspace = %q, want ws-task", session.WorkspaceID) } } }) } } type bootQuickChatFixture struct { id string workspaceID string title string workflowID string origin string agentProfileID string metadata map[string]interface{} updatedAt time.Time sessionUpdatedAt time.Time archived bool withoutPrimary bool } func seedBootQuickChatTask(t *testing.T, repo *sqlitetaskrepo.Repository, ctx context.Context, f bootQuickChatFixture) { t.Helper() workspaceID := f.workspaceID if workspaceID == "" { workspaceID = "ws-qc" } metadata := map[string]interface{}{models.MetaKeyAgentProfileID: f.agentProfileID} for key, value := range f.metadata { metadata[key] = value } if err := repo.CreateTask(ctx, &models.Task{ ID: f.id, WorkspaceID: workspaceID, WorkflowID: f.workflowID, Title: f.title, State: v1.TaskStateTODO, Priority: "medium", IsEphemeral: true, Origin: f.origin, Metadata: metadata, }); err != nil { t.Fatalf("CreateTask(%s): %v", f.id, err) } if _, err := repo.DB().ExecContext(ctx, `UPDATE tasks SET created_at = ?, updated_at = ? WHERE id = ?`, f.updatedAt.Add(-time.Hour), f.updatedAt, f.id, ); err != nil { t.Fatalf("backdate task(%s): %v", f.id, err) } if f.archived { if _, err := repo.DB().ExecContext(ctx, `UPDATE tasks SET archived_at = ? WHERE id = ?`, f.updatedAt, f.id); err != nil { t.Fatalf("archive task(%s): %v", f.id, err) } } if f.withoutPrimary { return } if err := repo.CreateTaskSession(ctx, &models.TaskSession{ ID: f.id + "-session", TaskID: f.id, AgentProfileID: f.agentProfileID, State: models.TaskSessionStateCompleted, StartedAt: f.sessionUpdatedAt.Add(-time.Hour), UpdatedAt: f.sessionUpdatedAt, IsPrimary: true, }); err != nil { t.Fatalf("CreateTaskSession(%s): %v", f.id, err) } } func TestBootRouteDataIntegrationRouteIncludesOnlyLocalContext(t *testing.T) { taskSvc, workflowSvc := newBootStateTestServices(t) ctx := context.Background() workspaces, err := taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } payload := bootPayload(ctx, nil, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, webapp.ClassifyRoute("/github")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { RouteData map[string]json.RawMessage `json:"routeData"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if _, ok := decoded.RouteData["github"]; ok { t.Fatal("integration boot data must not include provider GitHub results") } var contextData struct { ActiveWorkspaceID string `json:"activeWorkspaceId"` Workflows []struct { ID string `json:"id"` } `json:"workflows"` Steps []struct { ID string `json:"id"` } `json:"steps"` Repositories []struct { ID string `json:"id"` } `json:"repositories"` } if err := json.Unmarshal(decoded.RouteData["routeContext"], &contextData); err != nil { t.Fatalf("Unmarshal routeContext: %v", err) } if contextData.ActiveWorkspaceID != workspaces[0].ID { t.Fatalf("active workspace = %q, want %q", contextData.ActiveWorkspaceID, workspaces[0].ID) } if len(contextData.Workflows) == 0 || len(contextData.Steps) == 0 { t.Fatalf("expected local workflow context, got %+v", contextData) } } func TestBootRouteDataIntegrationRouteUsesActiveWorkspaceCookie(t *testing.T) { taskSvc, workflowSvc := newBootStateTestServices(t) ctx := context.Background() if _, err := taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{ Name: "Cookie Workspace", }); err != nil { t.Fatalf("CreateWorkspace: %v", err) } workspace := nonFallbackWorkspace(t, ctx, taskSvc) if _, err := taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ WorkspaceID: workspace.ID, Name: "Cookie Workflow", }); err != nil { t.Fatalf("CreateWorkflow: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2Fgithub", nil) req.AddCookie(&http.Cookie{Name: "kandev-active-workspace", Value: workspace.ID}) payload := bootPayload(ctx, req, routeParams{ taskSvc: taskSvc, services: &Services{ Workflow: workflowSvc, }, }, webapp.ClassifyRoute("/github")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { RouteData struct { RouteContext struct { ActiveWorkspaceID string `json:"activeWorkspaceId"` } `json:"routeContext"` } `json:"routeData"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if decoded.RouteData.RouteContext.ActiveWorkspaceID != workspace.ID { t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.RouteContext.ActiveWorkspaceID, workspace.ID) } } func TestBootRouteDataIntegrationRouteCookieWinsOverSettingsWorkspace(t *testing.T) { harness := newBootStateTestHarness(t) ctx := context.Background() workspaces, err := harness.taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } if len(workspaces) == 0 { t.Fatal("expected seeded default workspace") } settingsWorkspaceID := workspaces[0].ID cookieWorkspace, err := harness.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{ Name: "Cookie Workspace", }) if err != nil { t.Fatalf("CreateWorkspace: %v", err) } if _, err := harness.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ WorkspaceID: cookieWorkspace.ID, Name: "Cookie Workflow", }); err != nil { t.Fatalf("CreateWorkflow: %v", err) } if _, err := harness.userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{ WorkspaceID: &settingsWorkspaceID, }); err != nil { t.Fatalf("UpdateUserSettings: %v", err) } req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2Fgithub", nil) req.AddCookie(&http.Cookie{Name: "kandev-active-workspace", Value: cookieWorkspace.ID}) payload := bootPayload(ctx, req, routeParams{ taskSvc: harness.taskSvc, userCtrl: harness.userCtrl, services: &Services{ Workflow: harness.workflowSvc, }, }, webapp.ClassifyRoute("/github")) raw, err := json.Marshal(payload) if err != nil { t.Fatalf("Marshal payload: %v", err) } var decoded struct { InitialState struct { Workspaces struct { ActiveID string `json:"activeId"` } `json:"workspaces"` UserSettings struct { WorkspaceID string `json:"workspaceId"` } `json:"userSettings"` } `json:"initialState"` RouteData struct { RouteContext struct { ActiveWorkspaceID string `json:"activeWorkspaceId"` } `json:"routeContext"` } `json:"routeData"` } if err := json.Unmarshal(raw, &decoded); err != nil { t.Fatalf("Unmarshal payload: %v", err) } if decoded.InitialState.Workspaces.ActiveID != cookieWorkspace.ID { t.Fatalf("initial active workspace = %q, want %q", decoded.InitialState.Workspaces.ActiveID, cookieWorkspace.ID) } if decoded.InitialState.UserSettings.WorkspaceID != cookieWorkspace.ID { t.Fatalf("user settings workspace = %q, want %q", decoded.InitialState.UserSettings.WorkspaceID, cookieWorkspace.ID) } if decoded.RouteData.RouteContext.ActiveWorkspaceID != cookieWorkspace.ID { t.Fatalf("route active workspace = %q, want %q", decoded.RouteData.RouteContext.ActiveWorkspaceID, cookieWorkspace.ID) } } func nonFallbackWorkspace(t *testing.T, ctx context.Context, taskSvc *taskservice.Service) *models.Workspace { t.Helper() workspaces, err := taskSvc.ListWorkspaces(ctx) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } if len(workspaces) < 2 { t.Fatalf("expected at least two workspaces, got %d", len(workspaces)) } fallbackID := workspaces[0].ID for _, workspace := range workspaces { if workspace != nil && workspace.ID != fallbackID { return workspace } } t.Fatal("expected a workspace different from the default fallback") return nil } func TestBootInitialStateSettingsWithNilServicesDoesNotPanic(t *testing.T) { state := bootInitialState( context.Background(), nil, routeParams{features: config.FeaturesConfig{Office: true}}, webapp.ClassifyRoute("/settings/prompts"), ) if _, ok := state["features"]; !ok { t.Fatal("features should always be present even when optional services are unavailable") } if _, ok := state["prompts"]; ok { t.Fatal("prompts should not be marked loaded when the prompts controller is unavailable") } } func TestQueryValueReadsRouteQueryFromAppStatePath(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/app-state?path=%2F%3FworkspaceId%3Dws-1%26workflowId%3Dwf-1", nil) if got := queryValue(req, "workspaceId"); got != "ws-1" { t.Fatalf("workspaceId = %q, want ws-1", got) } if got := queryValue(req, "workflowId"); got != "wf-1" { t.Fatalf("workflowId = %q, want wf-1", got) } } type bootStateTestHarness struct { db *sqlx.DB taskSvc *taskservice.Service taskRepo *sqlitetaskrepo.Repository workflowSvc *workflowservice.Service userCtrl *usercontroller.Controller userSvc *userservice.Service } func newBootStateTestServices(t *testing.T) (*taskservice.Service, *workflowservice.Service) { harness := newBootStateTestHarness(t) return harness.taskSvc, harness.workflowSvc } func newBootStateTestHarness(t *testing.T) bootStateTestHarness { t.Helper() dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("OpenSQLite: %v", err) } sqlxDB := sqlx.NewDb(dbConn, "sqlite3") taskRepo, cleanup, err := taskrepo.Provide(sqlxDB, sqlxDB, nil) if err != nil { t.Fatalf("task repository: %v", err) } if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil { t.Fatalf("worktree store: %v", err) } workflowRepo, err := workflowrepo.NewWithDB(sqlxDB, sqlxDB, nil) if err != nil { t.Fatalf("workflow repository: %v", err) } userRepo, userCleanup, err := userstore.Provide(sqlxDB, sqlxDB) if err != nil { t.Fatalf("user repository: %v", err) } t.Cleanup(func() { if err := sqlxDB.Close(); err != nil { t.Errorf("close db: %v", err) } if err := cleanup(); err != nil { t.Errorf("close task repo: %v", err) } if err := userCleanup(); err != nil { t.Errorf("close user repo: %v", err) } }) log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) eventBus := bus.NewMemoryEventBus(log) userSvc := userservice.NewService(userRepo, eventBus, log) workflowSvc := workflowservice.NewService(workflowRepo, log) t.Cleanup(func() { _ = workflowSvc.Close() }) taskSvc := taskservice.NewService( taskservice.Repos{ Workspaces: taskRepo, Tasks: taskRepo, TaskRepos: taskRepo, Workflows: taskRepo, Messages: taskRepo, Turns: taskRepo, Sessions: taskRepo, GitSnapshots: taskRepo, RepoEntities: taskRepo, Executors: taskRepo, Environments: taskRepo, TaskEnvironments: taskRepo, Reviews: taskRepo, StatusSummaries: taskRepo, }, eventBus, log, taskservice.RepositoryDiscoveryConfig{}, ) taskSvc.SetWorkflowStepCreator(workflowSvc) taskSvc.SetWorkspaceBootstrapper(taskRepo) taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc}) taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc}) workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc}) return bootStateTestHarness{ db: sqlxDB, taskSvc: taskSvc, taskRepo: taskRepo, workflowSvc: workflowSvc, userCtrl: usercontroller.NewController(userSvc), userSvc: userSvc, } } func TestNewWebAppHandlerUsesEmbeddedAssetsWithoutDistDir(t *testing.T) { t.Setenv("KANDEV_WEB_DIST_DIR", "") t.Chdir(t.TempDir()) handler, source, ok := newWebAppHandler(routeParams{}) if !ok { t.Fatal("expected embedded web app handler when no filesystem dist dir exists") } if source != "embedded" { t.Fatalf("source = %q, want embedded", source) } req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } if !strings.Contains(rec.Body.String(), "window.__KANDEV_BOOT_PAYLOAD__") { t.Fatal("embedded shell should receive boot payload injection") } } func TestResolveActiveOfficeWorkspaceIDPrefersCookie(t *testing.T) { workspaces := []taskdto.WorkspaceDTO{ {ID: "ws-a", OfficeWorkflowID: "office-a"}, {ID: "ws-b", OfficeWorkflowID: "office-b"}, } got := resolveActiveOfficeWorkspaceID(workspaces, "ws-b") if got != "ws-b" { t.Fatalf("expected cookie workspace to win, got %q", got) } } func TestAppendSessionStateMessage_IncludesUpdatedAt(t *testing.T) { updatedAt := time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC) session := &models.TaskSession{ ID: "sess-3", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, UpdatedAt: updatedAt, } msgs := appendSessionStateMessage(session.ID, session, nil) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } payload := decodePayload(t, msgs[0].Payload) got, present := payload["updated_at"] if !present { t.Fatal("payload missing updated_at — stale subscribe snapshots cannot be ignored") } if got != updatedAt.Format(time.RFC3339Nano) { t.Fatalf("expected updated_at=%q, got %v", updatedAt.Format(time.RFC3339Nano), got) } } func TestAppendContextWindowMessage_DoesNotEmitStateSnapshot(t *testing.T) { session := &models.TaskSession{ ID: "sess-4", TaskID: "task-1", State: models.TaskSessionStateRunning, UpdatedAt: time.Date(2026, 1, 2, 12, 0, 0, 0, time.UTC), Metadata: map[string]interface{}{ models.SessionMetaKeyContextWindow: map[string]interface{}{"size": 100}, models.SessionMetaKeyContextCompactionCount: int64(3), }, } msgs := appendContextWindowMessage(session.ID, session, nil) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } payload := decodePayload(t, msgs[0].Payload) if _, present := payload["new_state"]; present { t.Fatal("context-window snapshot must not carry new_state and overwrite fresher session state") } metadata, ok := payload["metadata"].(map[string]interface{}) if !ok { t.Fatal("context-window snapshot missing metadata") } if got := metadata[models.SessionMetaKeyContextCompactionCount]; got != float64(3) { t.Fatalf("expected compaction count 3, got %v", got) } } // TestAppendSessionStateMessage_OmitsEmptyTaskEnvironmentID — sessions without // an environment (legacy rows, archived sessions) must not emit an empty // task_environment_id field that would clobber a populated frontend map. func TestAppendSessionStateMessage_OmitsEmptyTaskEnvironmentID(t *testing.T) { session := &models.TaskSession{ ID: "sess-2", TaskID: "task-1", State: models.TaskSessionStateCompleted, } msgs := appendSessionStateMessage(session.ID, session, nil) if len(msgs) != 1 { t.Fatalf("expected 1 message, got %d", len(msgs)) } payload := decodePayload(t, msgs[0].Payload) if _, present := payload["task_environment_id"]; present { t.Fatalf("payload should not include task_environment_id when session has none") } } func TestExternalMCPOpenMiddleware_AllowsLoopbackAndRemote(t *testing.T) { r := setupExternalMCPAccessRouter() for _, tc := range []struct{ name, remoteAddr string }{ {"loopback", "127.0.0.1:4321"}, {"remote", "203.0.113.10:4321"}, } { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/mcp", nil) req.RemoteAddr = tc.remoteAddr r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("request = %d, want %d", w.Code, http.StatusOK) } }) } } func setupExternalMCPAccessRouter() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() // nil auth service = auth feature absent = open (disabled-mode behavior). // PAT enforcement when auth is enabled is covered by internal/auth tests. r.Use(externalMCPAuthMiddleware(nil)) r.GET("/mcp", func(c *gin.Context) { c.Status(http.StatusOK) }) return r }