From 3d603cebccede0ac2c6525796b7ff2a8c074449d Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 6 Jul 2026 11:05:03 +0200 Subject: [PATCH] feat(workstate): dette de contrat DTO work-state (frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligne le contrat DTO du panneau work-state (#5). - ajout summary: string|null à BackgroundCompletion, mappé depuis task.summary - affichage discret du résumé sur une tâche terminale - suppression du chemin de merge top-level mort des background tasks Tests verts : vitest workstate.test.tsx (21), suite complète (507), npm run build (exit 0). Co-Authored-By: Claude Opus 4.8 --- .../src/adapters/workStateNormalization.ts | 17 +-- frontend/src/domain/index.ts | 2 + .../workstate/ProjectWorkStatePanel.tsx | 14 +++ .../src/features/workstate/workstate.test.tsx | 111 ++++++++++++++++++ 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index 40f832f..295c73d 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -107,6 +107,7 @@ function normalizeBackgroundTask( kind: stringValue(task.kind, "background"), status: normalizeBackgroundStatus(task.status ?? task.state), exitCode: nullableNumber(task.exitCode), + summary: nullableString(task.summary), stdoutTail: nullableString(task.stdoutTail), stderrTail: nullableString(task.stderrTail), updatedAtMs: numberValue(task.updatedAtMs), @@ -215,17 +216,11 @@ function normalizeConversation( export function normalizeProjectWorkState(value: unknown): ProjectWorkState { const state = isRecord(value) ? value : {}; const agents = arrayValue(state.agents).map(normalizeAgent); - const topLevelTasks = arrayValue( - state.backgroundTasks ?? state.backgroundCompletions, - ).map((task, index) => normalizeBackgroundTask(task, index, "")); - for (const task of topLevelTasks) { - const owner = agents.find((agent) => agent.agentId === task.ownerAgentId); - if (!owner) continue; - owner.backgroundTasks ??= []; - if (!owner.backgroundTasks.some((item) => item.taskId === task.taskId)) { - owner.backgroundTasks.push(task); - } - } + // Background tasks are always emitted nested under their owning agent + // (`agent.backgroundTasks`) and the backend DTO carries no `ownerAgentId`, so a + // top-level merge keyed on `task.ownerAgentId` is unreachable for this contract + // and has been removed (ticket #5). The per-agent `ownerAgentId = agentId` + // fallback in `normalizeBackgroundTask` is what nested tasks rely on. const topLevelInbox = arrayValue(state.inboxItems).map((item, index) => normalizeInboxItem(item, index, ""), ); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 8db7be6..da32256 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -330,6 +330,8 @@ export interface BackgroundCompletion { kind: string; status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered"; exitCode: number | null; + /** Human-readable summary / error / reason of a terminal result (ticket #5). */ + summary: string | null; stdoutTail: string | null; stderrTail: string | null; /** Last-update timestamp (epoch ms); chronological ordering key. */ diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index 85a539e..f9d8ecf 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -104,6 +104,11 @@ function formatTaskStatus(status: BackgroundCompletion["status"]): string { } } +/** Terminal (finished) task states that carry a result summary (ticket #5). */ +function isTerminalTaskStatus(status: BackgroundCompletion["status"]): boolean { + return status === "completed" || status === "failed" || status === "cancelled"; +} + function taskStatusClass(status: BackgroundCompletion["status"]): string { if (status === "running" || status === "pending") return "bg-warning/15 text-warning"; if (status === "completed" || status === "delivered") return "bg-success/10 text-success"; @@ -407,6 +412,15 @@ function BackgroundTaskRow({ {shortTicket(task.taskId)} + {task.summary && isTerminalTaskStatus(task.status) && ( +

+ {task.summary} +

+ )} {message && (

{message} diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index 9d18a3c..a16253a 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -208,6 +208,7 @@ describe("ProjectWorkStatePanel", () => { kind: "command", state: "failed", exitCode: 1, + summary: "process failed", stderrTail: "boom", createdAtMs: 14, updatedAtMs: 15, @@ -236,7 +237,10 @@ describe("ProjectWorkStatePanel", () => { // Optional fields omitted by the backend normalize to null (not undefined/NaN). expect(tasks[0]?.exitCode).toBeNull(); expect(tasks[0]?.stdoutTail).toBeNull(); + expect(tasks[0]?.summary).toBeNull(); expect(tasks[2]?.stderrTail).toBe("boom"); + // `summary` (ticket #5) is carried through when present. + expect(tasks[2]?.summary).toBe("process failed"); // updatedAtMs (backend-emitted) is carried through for chronological ordering. expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]); @@ -259,6 +263,72 @@ describe("ProjectWorkStatePanel", () => { expect(order.every((i) => i >= 0)).toBe(true); }); + it("shows a terminal task summary and hides it when absent or non-terminal (#5)", async () => { + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-summary", + name: "Reporter", + profileId: "codex", + busy: { state: "idle" }, + backgroundTasks: [ + { + taskId: "run-with-summary", + kind: "command", + state: "running", + summary: "should-not-appear-while-running", + createdAtMs: 40, + updatedAtMs: 40, + }, + { + taskId: "done-with-summary", + kind: "command", + state: "completed", + summary: "Build succeeded in 12s", + createdAtMs: 30, + updatedAtMs: 30, + }, + { + taskId: "fail-with-summary", + kind: "command", + state: "failed", + exitCode: 1, + summary: "Compile error: missing semicolon", + createdAtMs: 20, + updatedAtMs: 20, + }, + { + taskId: "done-no-summary", + kind: "command", + state: "completed", + createdAtMs: 10, + updatedAtMs: 10, + }, + ], + }, + ], + }); + + renderPanel(workState); + + const list = await screen.findByLabelText("Reporter background tasks"); + // Terminal tasks with a summary render it discreetly. + expect( + within(list).getByLabelText("task done-wit summary").textContent, + ).toBe("Build succeeded in 12s"); + expect( + within(list).getByText("Compile error: missing semicolon"), + ).toBeTruthy(); + // A completed task without a summary shows no summary line. + expect(within(list).queryByLabelText("task done-no- summary")).toBeNull(); + // A still-running task never shows its (non-terminal) summary. + expect(within(list).queryByLabelText("task run-with summary")).toBeNull(); + expect( + within(list).queryByText("should-not-appear-while-running"), + ).toBeNull(); + }); + it("renders a legacy agent without tickets", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { @@ -280,6 +350,47 @@ describe("ProjectWorkStatePanel", () => { expect(screen.queryByLabelText("Legacy tickets")).toBeNull(); }); + it("ignores a stray top-level backgroundTasks array (nested tasks intact) (#5)", async () => { + // The backend nests tasks under their agent and emits no `ownerAgentId`; a + // project-root `backgroundTasks` array is not part of the contract and must + // not be merged in (dead path removed in ticket #5). + const workState = new MockWorkStateGateway(); + workState._setProjectWorkState(PROJECT_ID, { + agents: [ + { + agentId: "agent-nested", + name: "Nested", + profileId: "codex", + busy: { state: "idle" }, + backgroundTasks: [ + { + taskId: "nested-1", + kind: "command", + state: "completed", + createdAtMs: 1, + updatedAtMs: 2, + }, + ], + }, + ], + backgroundTasks: [ + { + taskId: "orphan-top-level", + kind: "command", + state: "completed", + ownerAgentId: "agent-nested", + createdAtMs: 3, + updatedAtMs: 4, + }, + ], + }); + + const state = await workState.getProjectWorkState(PROJECT_ID); + expect((state.agents[0]?.backgroundTasks ?? []).map((t) => t.taskId)).toEqual( + ["nested-1"], + ); + }); + it("normalizes missing top-level work state arrays", async () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, {});