Files
kandev/apps/backend/internal/task/service/service_turns_test.go
T
José Almeida 45ee2ff48f feat: key terminals + dockview layout to TaskEnvironment (#755)
* feat(backend): key user shells by TaskEnvironmentID

Sessions in the same task share a TaskEnvironment. Re-key the user shell
runner so terminals are scoped per env, not per session — switching between
sessions of the same task now sees the same shell list.

- Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest
  / executor.LaunchAgentRequest, propagated through ToAgentExecution and the
  orchestrator launch path.
- Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries
  it, falls back to sessionID for legacy paths.
- Rename runner param sessionID -> scopeID in interactive_shells.go; map keys
  are now scopeID:terminalID.
- Shell handlers + WS terminal handler resolve sid -> scopeID via
  ResolveScopeKey before calling the runner.

* feat(web): key dockview layout + portals to TaskEnvironmentID

Switching between sessions of the same task no longer rebuilds the dockview
layout — terminals and panels persist because layouts are env-scoped, not
session-scoped. The bottom terminal also reuses a stable sessionId across
same-env switches so it doesn't reconnect to a different shell.

- Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState
  -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix).
- Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch
  -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId.
- dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout
  -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op.
  Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it
  structurally.
- panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv.
  ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED).
- useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only.
- All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve
  sid -> envId via environmentIdBySessionId before switching.
- bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId().
- cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped
  layout/maximize cleanup.

* test: add coverage for env-keyed terminals + layout

Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of
the same task keeps terminal scrollback intact and asserts both sessions
share one task_environment_id. Update the comment in
sessionless-task-switch.spec.ts to reference performEnvSwitch.

* fix: address env layout PR feedback

* test: fix flaky handshake auth check

* fix: address env scope review findings
2026-05-01 11:00:33 +01:00

215 lines
6.1 KiB
Go

package service
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
)
func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
setupTestTask(t, repo)
now := time.Now().UTC()
session := &models.TaskSession{
ID: "session-1",
TaskID: "task-123",
TaskEnvironmentID: "env-123",
AgentProfileID: "profile-1",
State: models.TaskSessionStateCompleted,
AgentProfileSnapshot: map[string]interface{}{
"agent_name": "auggie",
},
Metadata: map[string]interface{}{
"acp_session_id": "acp-123",
},
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
// Add a worktree to the session
if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{
ID: "wt1",
SessionID: "session-1",
WorktreeID: "wid1",
RepositoryID: "repo1",
WorktreePath: "/tmp/worktrees/session-1",
WorktreeBranch: "feature/test",
CreatedAt: now,
}); err != nil {
t.Fatalf("failed to create worktree: %v", err)
}
info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1")
if err != nil {
t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err)
}
if info.TaskID != "task-123" {
t.Errorf("expected TaskID 'task-123', got %q", info.TaskID)
}
if info.SessionID != "session-1" {
t.Errorf("expected SessionID 'session-1', got %q", info.SessionID)
}
if info.TaskEnvironmentID != "env-123" {
t.Errorf("expected TaskEnvironmentID 'env-123', got %q", info.TaskEnvironmentID)
}
if info.WorkspacePath != "/tmp/worktrees/session-1" {
t.Errorf("expected WorkspacePath '/tmp/worktrees/session-1', got %q", info.WorkspacePath)
}
if info.AgentProfileID != "profile-1" {
t.Errorf("expected AgentProfileID 'profile-1', got %q", info.AgentProfileID)
}
if info.AgentID != "auggie" {
t.Errorf("expected AgentID 'auggie', got %q", info.AgentID)
}
if info.ACPSessionID != "acp-123" {
t.Errorf("expected ACPSessionID 'acp-123', got %q", info.ACPSessionID)
}
}
func TestGetWorkspaceInfoForSession_InfersTaskID(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
setupTestTask(t, repo)
now := time.Now().UTC()
session := &models.TaskSession{
ID: "session-1",
TaskID: "task-123",
State: models.TaskSessionStateCompleted,
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
// Pass empty taskID - should be inferred from the session
info, err := svc.GetWorkspaceInfoForSession(ctx, "", "session-1")
if err != nil {
t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err)
}
if info.TaskID != "task-123" {
t.Errorf("expected TaskID 'task-123' inferred from session, got %q", info.TaskID)
}
}
func TestGetWorkspaceInfoForSession_ExecutorInfo(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
setupTestTask(t, repo)
now := time.Now().UTC()
// Create executor
exec := &models.Executor{
ID: "exec-1",
Name: "My Sprites Executor",
Type: models.ExecutorTypeSprites,
Status: "active",
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.CreateExecutor(ctx, exec); err != nil {
t.Fatalf("failed to create executor: %v", err)
}
// Create session with executor reference
session := &models.TaskSession{
ID: "session-1",
TaskID: "task-123",
ExecutorID: "exec-1",
State: models.TaskSessionStateCompleted,
AgentProfileSnapshot: map[string]interface{}{
"agent_name": "auggie",
},
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
// Create executor running record
if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er-1",
SessionID: "session-1",
TaskID: "task-123",
ExecutorID: "exec-1",
Runtime: "sprites",
AgentExecutionID: "agent-exec-abc123",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
t.Fatalf("failed to upsert executor running: %v", err)
}
info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1")
if err != nil {
t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err)
}
if info.ExecutorType != "sprites" {
t.Errorf("expected ExecutorType 'sprites', got %q", info.ExecutorType)
}
if info.RuntimeName != "sprites" {
t.Errorf("expected RuntimeName 'sprites', got %q", info.RuntimeName)
}
if info.AgentExecutionID != "agent-exec-abc123" {
t.Errorf("expected AgentExecutionID 'agent-exec-abc123', got %q", info.AgentExecutionID)
}
}
func TestGetWorkspaceInfoForSession_NoExecutorRunning(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
setupTestTask(t, repo)
now := time.Now().UTC()
session := &models.TaskSession{
ID: "session-1",
TaskID: "task-123",
State: models.TaskSessionStateCompleted,
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
// No executor running record - should still succeed with empty executor fields
info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-1")
if err != nil {
t.Fatalf("GetWorkspaceInfoForSession returned error: %v", err)
}
if info.RuntimeName != "" {
t.Errorf("expected empty RuntimeName, got %q", info.RuntimeName)
}
if info.AgentExecutionID != "" {
t.Errorf("expected empty AgentExecutionID, got %q", info.AgentExecutionID)
}
if info.ExecutorType != "" {
t.Errorf("expected empty ExecutorType, got %q", info.ExecutorType)
}
}
func TestGetWorkspaceInfoForSession_SessionNotFound(t *testing.T) {
svc, _, _ := createTestService(t)
ctx := context.Background()
_, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent session")
}
}