fix: derive sidebar session state from most active session (#697)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user