feat(workstate): dette de contrat DTO work-state (frontend)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 11:05:03 +02:00
parent 4bdffa4baa
commit 3d603cebcc
4 changed files with 133 additions and 11 deletions

View File

@ -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, ""),
);

View File

@ -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. */

View File

@ -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)}
</code>
</div>
{task.summary && isTerminalTaskStatus(task.status) && (
<p
aria-label={`task ${shortTicket(task.taskId)} summary`}
title={task.summary}
className="mt-1 truncate pl-1 text-[11px] text-muted"
>
{task.summary}
</p>
)}
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}

View File

@ -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, {});