2098 lines
72 KiB
Go
2098 lines
72 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"
|
|
"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)
|
|
}
|
|
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID,
|
|
WorkflowStepID: steps[0].ID, Title: "Production unarchive wiring",
|
|
})
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
task, err := taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID,
|
|
WorkflowID: workflows[0].ID,
|
|
WorkflowStepID: steps[0].ID,
|
|
Title: "Boot home 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)
|
|
}
|
|
task, 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",
|
|
})
|
|
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)
|
|
}
|
|
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID,
|
|
WorkflowID: workflows[0].ID,
|
|
WorkflowStepID: steps[0].ID,
|
|
Title: "Visible sibling 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)
|
|
}
|
|
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID,
|
|
WorkflowID: workflows[0].ID,
|
|
WorkflowStepID: steps[0].ID,
|
|
Title: "Valid detail 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)
|
|
}
|
|
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID,
|
|
Title: "Shell output projection",
|
|
IsEphemeral: true,
|
|
})
|
|
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)
|
|
}
|
|
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID,
|
|
WorkflowStepID: steps[0].ID, Title: "Hydrated model selector",
|
|
})
|
|
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)
|
|
}
|
|
task, err := harness.taskSvc.CreateTask(ctx, &taskservice.CreateTaskRequest{
|
|
WorkspaceID: workspaces[0].ID, WorkflowID: workflows[0].ID,
|
|
WorkflowStepID: steps[0].ID, Title: "Hydrated turn snapshot",
|
|
})
|
|
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)
|
|
}
|
|
created, 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",
|
|
})
|
|
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
|
|
}
|