fix: derive sidebar session state from most active session (#697)

This commit is contained in:
Carlos Florêncio
2026-04-25 13:44:06 +01:00
committed by GitHub
parent 627b4aee98
commit efd68db15b
3 changed files with 212 additions and 1 deletions
@@ -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,
});
});
});
+78
View File
@@ -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>): 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");
});
});
+32 -1
View File
@@ -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<string, TaskSession[]>,
@@ -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 };