From b68f2d1fb851ede7db1ae4b9b72954826ea684e2 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 16 Jun 2026 15:01:17 +0900 Subject: [PATCH] Mark completed hook runs as completed --- .../mappers/message-stream/hook-run-items.ts | 22 ++++++++++++++++++- .../chat/message-stream/model.test.ts | 14 ++++++++++++ .../chat/protocol/inbound/controller.test.ts | 1 + .../chat/ui/message-stream/work-log.test.tsx | 4 ++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/features/chat/app-server/mappers/message-stream/hook-run-items.ts b/src/features/chat/app-server/mappers/message-stream/hook-run-items.ts index acf333b6..9b12e175 100644 --- a/src/features/chat/app-server/mappers/message-stream/hook-run-items.ts +++ b/src/features/chat/app-server/mappers/message-stream/hook-run-items.ts @@ -1,5 +1,5 @@ import { definedProp } from "../../../../../utils"; -import type { HookMessageStreamItem } from "../../../domain/message-stream/items"; +import type { ExecutionState, HookMessageStreamItem } from "../../../domain/message-stream/items"; interface MessageStreamHookRun { id: string; @@ -10,6 +10,17 @@ interface MessageStreamHookRun { entries: readonly { kind: string; text: string }[]; } +type MessageStreamExecutionState = Exclude; +type ExecutionStateByStatus = Readonly>; + +const HOOK_RUN_STATES = { + running: "running", + completed: "completed", + failed: "failed", + blocked: "failed", + stopped: "failed", +} as const satisfies ExecutionStateByStatus; + export function hookRunMessageStreamItem(run: MessageStreamHookRun, turnId: string | null, status: string): HookMessageStreamItem | null { if (run.id.length === 0) return null; const eventName = hookEventName(run.eventName); @@ -25,6 +36,7 @@ export function hookRunMessageStreamItem(run: MessageStreamHookRun, turnId: stri sourceItemId: displayId, provenance: { source: "appServer", channel: "notification", event: "hookRun", sourceItemId: displayId }, status, + executionState: hookRunExecutionState(status), hookRun: { eventName, ...definedProp("statusMessage", run.statusMessage ?? undefined), @@ -35,6 +47,14 @@ export function hookRunMessageStreamItem(run: MessageStreamHookRun, turnId: stri }; } +function hookRunExecutionState(status: string): ExecutionState { + return executionStateFromStatus(status, HOOK_RUN_STATES); +} + +function executionStateFromStatus(status: string, states: ExecutionStateByStatus): ExecutionState { + return states[status] ?? null; +} + function hookRunDisplayId(run: MessageStreamHookRun): string { return `hook-${run.id}-${run.startedAt.toString()}`; } diff --git a/tests/features/chat/message-stream/model.test.ts b/tests/features/chat/message-stream/model.test.ts index 343753dc..4f1e28b7 100644 --- a/tests/features/chat/message-stream/model.test.ts +++ b/tests/features/chat/message-stream/model.test.ts @@ -12,6 +12,7 @@ import { createAutoReviewResultItem, createReviewResultItem, } from "../../../../src/features/chat/app-server/mappers/message-stream/review-result-items"; +import { hookRunMessageStreamItem } from "../../../../src/features/chat/app-server/mappers/message-stream/hook-run-items"; import { messageStreamItemFromTurnItem, messageStreamItemsFromTurns, @@ -1437,6 +1438,8 @@ describe("execution state uses typed status adapters before rendered text", () = ).toMatchObject({ executionState: "running" }); expect(autoReviewItem("approved")).toMatchObject({ executionState: "completed" }); expect(autoReviewItem("timedOut")).toMatchObject({ executionState: "failed" }); + expect(hookRunMessageStreamItem(hookRun(), "turn", "completed")).toMatchObject({ executionState: "completed" }); + expect(hookRunMessageStreamItem(hookRun(), "turn", "stopped")).toMatchObject({ executionState: "failed" }); expect(collabAgentStateExecutionState("inProgress")).toBe("running"); expect(collabAgentStateExecutionState("failed")).toBe("failed"); }); @@ -1569,3 +1572,14 @@ function autoReviewItem(status: string): MessageStreamItem { action: { type: "applyPatch", cwd: "/vault", files: ["/vault/src/main.ts"] }, }); } + +function hookRun() { + return { + id: "hook-1", + eventName: "postToolUse", + statusMessage: "Formatted 1 file.", + startedAt: 1n, + durationMs: 1n, + entries: [{ kind: "feedback", text: "ok" }], + }; +} diff --git a/tests/features/chat/protocol/inbound/controller.test.ts b/tests/features/chat/protocol/inbound/controller.test.ts index 4f1c49d8..8fc5ff84 100644 --- a/tests/features/chat/protocol/inbound/controller.test.ts +++ b/tests/features/chat/protocol/inbound/controller.test.ts @@ -268,6 +268,7 @@ describe("ChatInboundController", () => { operation: "postToolUse", primaryTarget: { kind: "value", value: "Formatted 1 file." }, status: "completed", + executionState: "completed", output: "", hookRun: { eventName: "postToolUse", diff --git a/tests/features/chat/ui/message-stream/work-log.test.tsx b/tests/features/chat/ui/message-stream/work-log.test.tsx index c3c3e3ab..95cfb5be 100644 --- a/tests/features/chat/ui/message-stream/work-log.test.tsx +++ b/tests/features/chat/ui/message-stream/work-log.test.tsx @@ -259,6 +259,7 @@ describe("work log renderer decisions", () => { toolName: "hook", turnId: "turn", status: "completed", + executionState: "completed", hookRun: { eventName: "postToolUse", statusMessage: "Formatted 1 file.", @@ -276,6 +277,7 @@ describe("work log renderer decisions", () => { const element = renderMessageBlockElement(block); expect(topLevelDetailsSummaries(element)).toEqual(["hook"]); + expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["hook"]); expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); expect(element.textContent).not.toContain("Details"); @@ -302,6 +304,7 @@ describe("work log renderer decisions", () => { toolName: "hook", turnId: "turn", status: "completed", + executionState: "completed", hookRun: { eventName: "postToolUse", statusMessage: "Formatted 1 file.", @@ -328,6 +331,7 @@ describe("work log renderer decisions", () => { expect(element).toBeDefined(); expect(element.querySelector(":scope > summary")?.textContent).toBe("Work details: hook"); + expect(element.querySelector(".codex-panel__tool-item--hook")?.classList.contains("codex-panel__execution--completed")).toBe(true); expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse");