Files
kandev/apps/backend/internal/backendapp/helpers_test.go
T
2026-08-12 08:12:53 +01:00

2141 lines
73 KiB
Go

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: "kandev",
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: "starting", State: models.TaskSessionStateStarting},
{ID: "completed", State: models.TaskSessionStateCompleted},
{ID: "failed", State: models.TaskSessionStateFailed},
}
actions := map[string]models.TaskPendingAction{
"starting": 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)
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
}