266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import { test, expect } from "../../fixtures/office-fixture";
|
|
|
|
/**
|
|
* E2E coverage for the office task session lifecycle (per-(task, agent)
|
|
* persistent sessions, RUNNING ↔ IDLE state cycling, COMPLETED only on
|
|
* participation removal). Spec:
|
|
* docs/specs/office/requirements/tasks.md
|
|
*
|
|
* Harness limitations:
|
|
* - The /api/v1/_test/task-sessions route only INSERTS new rows; it does
|
|
* not update an existing row's state. Tests work around this by:
|
|
* a) seeding a session with the desired state directly, OR
|
|
* b) using the real reactivity endpoints (assignee change, reviewer
|
|
* removal) and asserting the row's final state via the public
|
|
* `/api/v1/tasks/:id/sessions` endpoint.
|
|
* - IDLE-from-RUNNING transition for a single seeded row is not directly
|
|
* expressible — the harness has no UPDATE path. The fire-and-forget
|
|
* test asserts spinner visibility on a freshly-seeded RUNNING row, and
|
|
* then verifies a freshly-seeded IDLE row does NOT light the spinner.
|
|
* Both observations validate the same selector contract
|
|
* (`selectLiveSessionForTask` only treats RUNNING as live for office).
|
|
*/
|
|
|
|
test.describe("Office task session lifecycle", () => {
|
|
test("fire-and-forget: RUNNING lights the spinner, IDLE does not", async ({
|
|
testPage,
|
|
apiClient,
|
|
officeSeed,
|
|
}) => {
|
|
const task = await apiClient.createTask(
|
|
officeSeed.workspaceId,
|
|
"Lifecycle Fire-and-Forget Task",
|
|
{ workflow_id: officeSeed.workflowId },
|
|
);
|
|
|
|
// RUNNING: topbar spinner visible, timeline entry expanded.
|
|
const startedAt = new Date(Date.now() - 30_000).toISOString();
|
|
const { session_id } = await apiClient.seedTaskSession(task.id, {
|
|
state: "RUNNING",
|
|
agentProfileId: officeSeed.agentId,
|
|
startedAt,
|
|
});
|
|
|
|
await testPage.goto(`/office/tasks/${task.id}`);
|
|
await expect(
|
|
testPage.getByRole("heading", { name: "Lifecycle Fire-and-Forget Task" }),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
await expect(testPage.getByTestId("topbar-working-active")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
// Office sessions surface as per-agent tabs in chat-activity-tabs
|
|
// since the Chat tab refactor — the tab id is the representative
|
|
// session id (for a single session that matches session_id) and
|
|
// its content is an AdvancedChatPanel embed keyed by the active
|
|
// session id.
|
|
await testPage.getByTestId(`agent-tab-${session_id}`).click();
|
|
await expect(testPage.getByTestId(`agent-tab-embed-${session_id}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// Now seed a SECOND task with an IDLE session and assert the spinner
|
|
// is absent. (We can't update the original row in place — see file
|
|
// header for harness limitation.)
|
|
const idleTask = await apiClient.createTask(officeSeed.workspaceId, "Lifecycle Idle Task", {
|
|
workflow_id: officeSeed.workflowId,
|
|
});
|
|
const { session_id: idleSessionId } = await apiClient.seedTaskSession(idleTask.id, {
|
|
state: "IDLE",
|
|
agentProfileId: officeSeed.agentId,
|
|
startedAt,
|
|
});
|
|
|
|
await testPage.goto(`/office/tasks/${idleTask.id}`);
|
|
await expect(testPage.getByRole("heading", { name: "Lifecycle Idle Task" })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// IDLE office session does NOT count as live → no topbar spinner.
|
|
await expect(testPage.getByTestId("topbar-working-indicator")).toHaveCount(0);
|
|
|
|
// IDLE office sessions still produce a per-agent tab — IDLE is a
|
|
// paused conversation, not a deletion. Click the tab and confirm
|
|
// its embed mounts.
|
|
await testPage.getByTestId(`agent-tab-${idleSessionId}`).click();
|
|
await expect(testPage.getByTestId(`agent-tab-embed-${idleSessionId}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
|
|
test("multi-agent: assignee + reviewer get separate timeline entries", async ({
|
|
testPage,
|
|
apiClient,
|
|
officeApi,
|
|
officeSeed,
|
|
}) => {
|
|
// Add a second agent to act as reviewer.
|
|
const reviewerAgent = (await officeApi.createAgent(officeSeed.workspaceId, {
|
|
name: "Lifecycle Reviewer",
|
|
role: "worker",
|
|
})) as Record<string, unknown>;
|
|
const reviewerId = reviewerAgent.id as string;
|
|
expect(reviewerId).toBeTruthy();
|
|
|
|
const task = await apiClient.createTask(officeSeed.workspaceId, "Lifecycle Multi Agent Task", {
|
|
workflow_id: officeSeed.workflowId,
|
|
});
|
|
|
|
// Add the reviewer to the task's reviewers list.
|
|
const addRes = await apiClient.rawRequest("POST", `/api/v1/office/tasks/${task.id}/reviewers`, {
|
|
agent_profile_id: reviewerId,
|
|
});
|
|
expect(addRes.ok).toBe(true);
|
|
|
|
// Seed two RUNNING sessions on the same task — one per agent.
|
|
const startedAt = new Date(Date.now() - 60_000).toISOString();
|
|
const assigneeSeed = await apiClient.seedTaskSession(task.id, {
|
|
state: "RUNNING",
|
|
agentProfileId: officeSeed.agentId,
|
|
startedAt,
|
|
});
|
|
const reviewerSeed = await apiClient.seedTaskSession(task.id, {
|
|
state: "RUNNING",
|
|
agentProfileId: reviewerId,
|
|
startedAt: new Date(Date.now() - 30_000).toISOString(),
|
|
});
|
|
|
|
await testPage.goto(`/office/tasks/${task.id}`);
|
|
await expect(testPage.getByRole("heading", { name: "Lifecycle Multi Agent Task" })).toBeVisible(
|
|
{ timeout: 10_000 },
|
|
);
|
|
|
|
// Both per-agent tabs must render (chat-activity-tabs.tsx groups
|
|
// by agent_profile_id, with one tab per group). The tab content
|
|
// is the AdvancedChatPanel embed; clicking each tab swaps the
|
|
// active session-id on the embed without spawning a separate
|
|
// SessionTimelineEntry (that surface was retired from the chat
|
|
// tab — see the chat-activity-tabs commentary).
|
|
const assigneeTab = testPage.getByTestId(`agent-tab-${assigneeSeed.session_id}`);
|
|
const reviewerTab = testPage.getByTestId(`agent-tab-${reviewerSeed.session_id}`);
|
|
await expect(assigneeTab).toBeVisible({ timeout: 10_000 });
|
|
await expect(reviewerTab).toBeVisible({ timeout: 10_000 });
|
|
|
|
await assigneeTab.click();
|
|
await expect(testPage.getByTestId(`agent-tab-embed-${assigneeSeed.session_id}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
await reviewerTab.click();
|
|
await expect(testPage.getByTestId(`agent-tab-embed-${reviewerSeed.session_id}`)).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
|
|
// Topbar spinner is present (any one RUNNING session is enough).
|
|
await expect(testPage.getByTestId("topbar-working-active")).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
});
|
|
|
|
test("reassignment: prev assignee's session moves to a terminal state", async ({
|
|
apiClient,
|
|
officeApi,
|
|
officeSeed,
|
|
}) => {
|
|
const newAssignee = (await officeApi.createAgent(officeSeed.workspaceId, {
|
|
name: "Lifecycle Reassignee",
|
|
role: "worker",
|
|
})) as Record<string, unknown>;
|
|
const newAssigneeId = newAssignee.id as string;
|
|
expect(newAssigneeId).toBeTruthy();
|
|
|
|
const task = await apiClient.createTask(officeSeed.workspaceId, "Lifecycle Reassignment Task", {
|
|
workflow_id: officeSeed.workflowId,
|
|
});
|
|
|
|
// Make CEO the formal assignee so the upcoming PATCH counts as a
|
|
// *reassignment* (prevAssignee non-empty) rather than a first-time
|
|
// assign — the reactivity pipeline only fires session-termination
|
|
// when there is a prior assignee to displace.
|
|
const assignRes = await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
|
|
assignee_agent_profile_id: officeSeed.agentId,
|
|
});
|
|
expect(assignRes.ok).toBe(true);
|
|
|
|
// Seed a RUNNING session for the original assignee (CEO from seed).
|
|
const startedAt = new Date(Date.now() - 30_000).toISOString();
|
|
const { session_id: originalSessionId } = await apiClient.seedTaskSession(task.id, {
|
|
state: "RUNNING",
|
|
agentProfileId: officeSeed.agentId,
|
|
startedAt,
|
|
});
|
|
|
|
// Reassign the task to the new agent via the office task PATCH.
|
|
const patchRes = await apiClient.rawRequest("PATCH", `/api/v1/office/tasks/${task.id}`, {
|
|
assignee_agent_profile_id: newAssigneeId,
|
|
});
|
|
expect(patchRes.ok).toBe(true);
|
|
|
|
// Verify (via the public listTaskSessions endpoint) that the original
|
|
// session is no longer in a non-terminal state. Per the spec, the
|
|
// reactivity pipeline + participants update terminate the prior
|
|
// assignee's session.
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const list = await apiClient.listTaskSessions(task.id);
|
|
const original = list.sessions.find((s) => s.id === originalSessionId);
|
|
return original?.state ?? "missing";
|
|
},
|
|
{ timeout: 10_000, intervals: [500, 1000] },
|
|
)
|
|
.toMatch(/^(COMPLETED|CANCELLED|FAILED)$/);
|
|
});
|
|
|
|
test("reviewer removal terminates the reviewer's session", async ({
|
|
apiClient,
|
|
officeApi,
|
|
officeSeed,
|
|
}) => {
|
|
const reviewerAgent = (await officeApi.createAgent(officeSeed.workspaceId, {
|
|
name: "Lifecycle Reviewer For Removal",
|
|
role: "worker",
|
|
})) as Record<string, unknown>;
|
|
const reviewerId = reviewerAgent.id as string;
|
|
expect(reviewerId).toBeTruthy();
|
|
|
|
const task = await apiClient.createTask(
|
|
officeSeed.workspaceId,
|
|
"Lifecycle Reviewer Removal Task",
|
|
{ workflow_id: officeSeed.workflowId },
|
|
);
|
|
|
|
const addRes = await apiClient.rawRequest("POST", `/api/v1/office/tasks/${task.id}/reviewers`, {
|
|
agent_profile_id: reviewerId,
|
|
});
|
|
expect(addRes.ok).toBe(true);
|
|
|
|
// Seed a RUNNING session for the reviewer.
|
|
const startedAt = new Date(Date.now() - 30_000).toISOString();
|
|
const { session_id: reviewerSessionId } = await apiClient.seedTaskSession(task.id, {
|
|
state: "RUNNING",
|
|
agentProfileId: reviewerId,
|
|
startedAt,
|
|
});
|
|
|
|
// Remove the reviewer via DELETE /tasks/:id/reviewers/:agentId.
|
|
const removeRes = await apiClient.rawRequest(
|
|
"DELETE",
|
|
`/api/v1/office/tasks/${task.id}/reviewers/${reviewerId}`,
|
|
);
|
|
expect(removeRes.ok).toBe(true);
|
|
|
|
// The reviewer's session must transition to a terminal state.
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const list = await apiClient.listTaskSessions(task.id);
|
|
const reviewerSession = list.sessions.find((s) => s.id === reviewerSessionId);
|
|
return reviewerSession?.state ?? "missing";
|
|
},
|
|
{ timeout: 10_000, intervals: [500, 1000] },
|
|
)
|
|
.toMatch(/^(COMPLETED|CANCELLED|FAILED)$/);
|
|
});
|
|
});
|