diff --git a/apps/web/e2e/tests/task/sidebar-secondary-session-status.spec.ts b/apps/web/e2e/tests/task/sidebar-secondary-session-status.spec.ts new file mode 100644 index 0000000000..fdfcf2f714 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-secondary-session-status.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; +const TASK_TITLE = "Secondary Session Status Task"; + +/** + * Regression: when a task already has a primary session that is idle + * ("Turn Finished") and the user opens a second chat tab, the sidebar must + * reflect the new session's RUNNING state. Before the fix the sidebar read + * the primary session's state only, so secondary tabs running in the + * background left the task showing "Turn Finished". + */ +test.describe("Sidebar status with secondary session", () => { + test("sidebar moves to Running when a non-primary chat tab starts", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + + // 1. Create task whose first session completes quickly. + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + TASK_TITLE, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + // 2. Wait for the first session to reach Turn Finished. + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return DONE_STATES.includes(sessions[0]?.state ?? ""); + }, + { timeout: 30_000, message: "Waiting for first session to finish" }, + ) + .toBe(true); + + // 3. Navigate to the task. + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + const card = kanban.taskCardByTitle(TASK_TITLE); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.click(); + await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ + timeout: 15_000, + }); + + // 4. Sanity: sidebar shows the task as "Turn Finished" before we start + // the secondary session. + await expect(session.taskInSection(TASK_TITLE, "Turn Finished")).toBeVisible({ + timeout: 15_000, + }); + + // 5. Open the new session dialog and start a slow second session that + // stays RUNNING long enough to assert the sidebar reaction. + await session.openNewSessionDialog(); + await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 }); + await session + .newSessionPromptInput() + .fill('e2e:message("starting...")\ne2e:delay(15000)\ne2e:message("done")'); + await session.newSessionStartButton().click(); + await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 }); + + // 6. Backend must have created a second session. + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.length; + }, + { timeout: 30_000, message: "Waiting for second session to appear" }, + ) + .toBe(2); + + // 7. Regression assertion: sidebar moves the task into the "Running" + // bucket while the secondary session is still mid-delay. + await expect(session.taskInSection(TASK_TITLE, "Running")).toBeVisible({ + timeout: 30_000, + }); + + // 8. After the delay completes, the task should land back in + // "Turn Finished" — proving the fix doesn't break the normal + // completion path either. + await expect(session.taskInSection(TASK_TITLE, "Turn Finished")).toBeVisible({ + timeout: 45_000, + }); + }); +}); diff --git a/apps/web/lib/utils/session-info.test.ts b/apps/web/lib/utils/session-info.test.ts new file mode 100644 index 0000000000..74e7eba204 --- /dev/null +++ b/apps/web/lib/utils/session-info.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; + +import { getSessionInfoForTask } from "./session-info"; +import type { TaskSession } from "@/lib/types/http"; + +function session(overrides: Partial): TaskSession { + return { + id: overrides.id ?? "s1", + task_id: overrides.task_id ?? "t1", + state: overrides.state ?? "WAITING_FOR_INPUT", + started_at: overrides.started_at ?? "", + updated_at: overrides.updated_at ?? "", + is_primary: overrides.is_primary ?? false, + ...overrides, + } as TaskSession; +} + +describe("getSessionInfoForTask", () => { + it("returns undefined sessionState when the task has no sessions", () => { + const info = getSessionInfoForTask("t1", {}, {}); + expect(info.sessionState).toBeUndefined(); + }); + + it("returns the primary session's state when it is the only session", () => { + const info = getSessionInfoForTask( + "t1", + { t1: [session({ id: "p", is_primary: true, state: "WAITING_FOR_INPUT" })] }, + {}, + ); + expect(info.sessionState).toBe("WAITING_FOR_INPUT"); + }); + + // Regression: the sidebar derives its "running" badge from this state. + // A secondary chat tab that the user opens while the primary is idle must + // still surface as "in progress" — without this the task keeps showing + // "Turn Finished" while the new agent is working. + it("returns RUNNING when any non-primary session is running, even if primary is idle", () => { + const info = getSessionInfoForTask( + "t1", + { + t1: [ + session({ id: "p", is_primary: true, state: "WAITING_FOR_INPUT" }), + session({ id: "s", state: "RUNNING" }), + ], + }, + {}, + ); + expect(info.sessionState).toBe("RUNNING"); + }); + + it("prefers RUNNING over STARTING", () => { + const info = getSessionInfoForTask( + "t1", + { + t1: [ + session({ id: "p", is_primary: true, state: "STARTING" }), + session({ id: "s", state: "RUNNING" }), + ], + }, + {}, + ); + expect(info.sessionState).toBe("RUNNING"); + }); + + it("falls back to the primary's state when no session is more active", () => { + const info = getSessionInfoForTask( + "t1", + { + t1: [ + session({ id: "p", is_primary: true, state: "WAITING_FOR_INPUT" }), + session({ id: "s", state: "COMPLETED" }), + ], + }, + {}, + ); + expect(info.sessionState).toBe("WAITING_FOR_INPUT"); + }); +}); diff --git a/apps/web/lib/utils/session-info.ts b/apps/web/lib/utils/session-info.ts index e9e6c016d2..b344ba67df 100644 --- a/apps/web/lib/utils/session-info.ts +++ b/apps/web/lib/utils/session-info.ts @@ -34,6 +34,37 @@ function computeDiffStats( return additions === 0 && deletions === 0 ? undefined : { additions, deletions }; } +// Activity ranking for the sidebar's status indicator. The primary session +// drives diff stats and updatedAt (those reflect the task's "default" view), +// but the sidebar's state badge should reflect whatever session is most +// active right now — otherwise a secondary chat tab running in the +// background leaves the task showing the idle primary's "Turn Finished" +// badge. Lower index = more active. +const SESSION_STATE_PRIORITY: TaskSessionState[] = [ + "RUNNING", + "STARTING", + "WAITING_FOR_INPUT", + "COMPLETED", + "FAILED", + "CANCELLED", + "CREATED", +]; + +function priority(state: TaskSessionState | undefined): number { + if (!state) return SESSION_STATE_PRIORITY.length; + const idx = SESSION_STATE_PRIORITY.indexOf(state); + return idx === -1 ? SESSION_STATE_PRIORITY.length : idx; +} + +function pickMostActiveState(sessions: TaskSession[]): TaskSessionState | undefined { + let best: TaskSessionState | undefined; + for (const s of sessions) { + const candidate = s.state as TaskSessionState | undefined; + if (priority(candidate) < priority(best)) best = candidate; + } + return best; +} + export function getSessionInfoForTask( taskId: string, sessionsByTaskId: Record, @@ -52,7 +83,7 @@ export function getSessionInfoForTask( // Empty string means the session was created from a WS event without timestamps; // return undefined so callers fall through to task.updatedAt/createdAt instead. const updatedAt = latestSession.updated_at || undefined; - const sessionState = latestSession.state as TaskSessionState | undefined; + const sessionState = pickMostActiveState(sessions); const envKey = environmentIdBySessionId?.[latestSession.id] ?? latestSession.id; const gitStatus = gitStatusByEnvId[envKey]; if (!gitStatus) return { diffStats: undefined, updatedAt, sessionState };