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:
@ -107,6 +107,7 @@ function normalizeBackgroundTask(
|
|||||||
kind: stringValue(task.kind, "background"),
|
kind: stringValue(task.kind, "background"),
|
||||||
status: normalizeBackgroundStatus(task.status ?? task.state),
|
status: normalizeBackgroundStatus(task.status ?? task.state),
|
||||||
exitCode: nullableNumber(task.exitCode),
|
exitCode: nullableNumber(task.exitCode),
|
||||||
|
summary: nullableString(task.summary),
|
||||||
stdoutTail: nullableString(task.stdoutTail),
|
stdoutTail: nullableString(task.stdoutTail),
|
||||||
stderrTail: nullableString(task.stderrTail),
|
stderrTail: nullableString(task.stderrTail),
|
||||||
updatedAtMs: numberValue(task.updatedAtMs),
|
updatedAtMs: numberValue(task.updatedAtMs),
|
||||||
@ -215,17 +216,11 @@ function normalizeConversation(
|
|||||||
export function normalizeProjectWorkState(value: unknown): ProjectWorkState {
|
export function normalizeProjectWorkState(value: unknown): ProjectWorkState {
|
||||||
const state = isRecord(value) ? value : {};
|
const state = isRecord(value) ? value : {};
|
||||||
const agents = arrayValue(state.agents).map(normalizeAgent);
|
const agents = arrayValue(state.agents).map(normalizeAgent);
|
||||||
const topLevelTasks = arrayValue(
|
// Background tasks are always emitted nested under their owning agent
|
||||||
state.backgroundTasks ?? state.backgroundCompletions,
|
// (`agent.backgroundTasks`) and the backend DTO carries no `ownerAgentId`, so a
|
||||||
).map((task, index) => normalizeBackgroundTask(task, index, ""));
|
// top-level merge keyed on `task.ownerAgentId` is unreachable for this contract
|
||||||
for (const task of topLevelTasks) {
|
// and has been removed (ticket #5). The per-agent `ownerAgentId = agentId`
|
||||||
const owner = agents.find((agent) => agent.agentId === task.ownerAgentId);
|
// fallback in `normalizeBackgroundTask` is what nested tasks rely on.
|
||||||
if (!owner) continue;
|
|
||||||
owner.backgroundTasks ??= [];
|
|
||||||
if (!owner.backgroundTasks.some((item) => item.taskId === task.taskId)) {
|
|
||||||
owner.backgroundTasks.push(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const topLevelInbox = arrayValue(state.inboxItems).map((item, index) =>
|
const topLevelInbox = arrayValue(state.inboxItems).map((item, index) =>
|
||||||
normalizeInboxItem(item, index, ""),
|
normalizeInboxItem(item, index, ""),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -330,6 +330,8 @@ export interface BackgroundCompletion {
|
|||||||
kind: string;
|
kind: string;
|
||||||
status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered";
|
status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered";
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
|
/** Human-readable summary / error / reason of a terminal result (ticket #5). */
|
||||||
|
summary: string | null;
|
||||||
stdoutTail: string | null;
|
stdoutTail: string | null;
|
||||||
stderrTail: string | null;
|
stderrTail: string | null;
|
||||||
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
||||||
|
|||||||
@ -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 {
|
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
||||||
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
|
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
|
||||||
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
||||||
@ -407,6 +412,15 @@ function BackgroundTaskRow({
|
|||||||
{shortTicket(task.taskId)}
|
{shortTicket(task.taskId)}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</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 && (
|
{message && (
|
||||||
<p role="alert" className="mt-1 text-xs text-danger">
|
<p role="alert" className="mt-1 text-xs text-danger">
|
||||||
{message}
|
{message}
|
||||||
|
|||||||
@ -208,6 +208,7 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
kind: "command",
|
kind: "command",
|
||||||
state: "failed",
|
state: "failed",
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
|
summary: "process failed",
|
||||||
stderrTail: "boom",
|
stderrTail: "boom",
|
||||||
createdAtMs: 14,
|
createdAtMs: 14,
|
||||||
updatedAtMs: 15,
|
updatedAtMs: 15,
|
||||||
@ -236,7 +237,10 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
// Optional fields omitted by the backend normalize to null (not undefined/NaN).
|
// Optional fields omitted by the backend normalize to null (not undefined/NaN).
|
||||||
expect(tasks[0]?.exitCode).toBeNull();
|
expect(tasks[0]?.exitCode).toBeNull();
|
||||||
expect(tasks[0]?.stdoutTail).toBeNull();
|
expect(tasks[0]?.stdoutTail).toBeNull();
|
||||||
|
expect(tasks[0]?.summary).toBeNull();
|
||||||
expect(tasks[2]?.stderrTail).toBe("boom");
|
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.
|
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
|
||||||
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
|
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);
|
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 () => {
|
it("renders a legacy agent without tickets", async () => {
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState(PROJECT_ID, {
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
@ -280,6 +350,47 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
expect(screen.queryByLabelText("Legacy tickets")).toBeNull();
|
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 () => {
|
it("normalizes missing top-level work state arrays", async () => {
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState(PROJECT_ID, {});
|
workState._setProjectWorkState(PROJECT_ID, {});
|
||||||
|
|||||||
Reference in New Issue
Block a user