diff --git a/tests/display/display-model.test.ts b/tests/display/display-model.test.ts index d055be18..7f3a3aeb 100644 --- a/tests/display/display-model.test.ts +++ b/tests/display/display-model.test.ts @@ -856,6 +856,51 @@ describe("display block grouping keeps work logs subordinate to conversation mes expect(blocks[1]).toMatchObject({ summary: "Work details: command, thought" }); }); + it("groups completed hook and review logs before the final assistant message", () => { + const items: DisplayItem[] = [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "userPromptSubmit: Saving jj baseline", + toolLabel: "hook", + turnId: "t1", + status: "completed", + }, + commandItem("c1", "npm test", "t1"), + { id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "t1", status: "completed" }, + { + id: "review-1", + kind: "reviewResult", + role: "tool", + text: "Auto-review approved: npm test", + turnId: "t1", + state: "completed", + }, + { + id: "review-2", + kind: "reviewResult", + role: "tool", + text: "Auto-review approved: npm test", + turnId: "t1", + state: "completed", + }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" }, + ]; + + const blocks = displayBlocksForItems(items, null); + + expect(blocks.map((block) => (block.type === "item" ? block.item.id : block.id))).toEqual(["u1", "turn-t1-activity", "a1"]); + expect(blocks[1]).toMatchObject({ + summary: "Work details: command, hook, thought, 2 reviews", + items: [{ id: "hook-1" }, { id: "c1" }, { id: "r1" }, { id: "review-1" }, { id: "review-2" }], + }); + expect(blocks[2]).toMatchObject({ + item: { id: "a1", autoReviewSummaries: ["Auto-review approved: npm test", "Auto-review approved: npm test"] }, + }); + }); + it("hides empty completed reasoning items", () => { const items: DisplayItem[] = [ { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" }, diff --git a/tests/panel/panel-controller.test.ts b/tests/panel/panel-controller.test.ts index b3f57f00..dd356442 100644 --- a/tests/panel/panel-controller.test.ts +++ b/tests/panel/panel-controller.test.ts @@ -440,6 +440,75 @@ describe("PanelController", () => { expect(state.pendingTurnStart?.promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); }); + it("keeps pre-turn prompt submit hooks through turn start and completed-turn reconciliation", () => { + const state = createPanelState(); + state.activeThreadId = "thread-active"; + state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] }; + state.displayItems = [{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }]; + const controller = controllerForState(state); + + controller.handleNotification({ + method: "hook/completed", + params: { threadId: "thread-active", turnId: null, run: promptSubmitHookRun("hook-1", 1n) }, + } satisfies Extract); + + expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(state.displayItems[1]?.turnId).toBeUndefined(); + expect(state.pendingTurnStart?.promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]); + + controller.handleNotification({ + method: "turn/started", + params: { + threadId: "thread-active", + turn: { + id: "turn-active", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + + expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]); + expect(state.displayItems.find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId"); + expect(state.displayItems.find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" }); + expect(state.pendingTurnStart).toBeNull(); + + controller.handleNotification({ + method: "turn/completed", + params: { + threadId: "thread-active", + turn: { + id: "turn-active", + status: "completed", + startedAt: 1, + completedAt: 2, + durationMs: 1, + error: null, + itemsView: "full", + items: [ + { type: "userMessage", id: "u1", content: [{ type: "text", text: "hello", text_elements: [] }] }, + { type: "agentMessage", id: "a1", text: "done", phase: "final_answer", memoryCitation: null }, + ], + }, + }, + } satisfies Extract); + + expect(state.displayItems.map((item) => item.id)).toEqual(["hook-hook-1-1", "u1", "a1"]); + expect(state.displayItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "u1", text: "hello", turnId: "turn-active" }), + expect.objectContaining({ id: "hook-hook-1-1", kind: "hook", turnId: "turn-active" }), + expect.objectContaining({ id: "a1", text: "done", turnId: "turn-active" }), + ]), + ); + expect(state.displayItems.some((item) => item.id === "local-user-1")).toBe(false); + }); + it("stores account rate limit updates outside thread scope", () => { const state = createPanelState(); const controller = controllerForState(state); @@ -1141,6 +1210,25 @@ function thread(id: string, cwd: string): Thread { }; } +function promptSubmitHookRun(id: string, startedAt: bigint): Extract["params"]["run"] { + return { + id, + eventName: "userPromptSubmit", + handlerType: "command", + executionMode: "sync", + scope: "turn", + sourcePath: "/vault/.codex/hooks.json", + source: "project", + displayOrder: 1n, + status: "completed", + statusMessage: "Saving jj baseline", + startedAt, + completedAt: startedAt + 1n, + durationMs: 1n, + entries: [], + }; +} + function unsupportedRequests(): ServerRequest[] { return [ { diff --git a/tests/ui/view-renderers.test.ts b/tests/ui/view-renderers.test.ts index 75dbc329..d89694ca 100644 --- a/tests/ui/view-renderers.test.ts +++ b/tests/ui/view-renderers.test.ts @@ -100,6 +100,68 @@ describe("message stream block identity and message actions", () => { expect([...parent.children]).toEqual([first, second]); }); + it("inserts completed-turn activity groups without replacing stable conversation nodes", () => { + const parent = document.createElement("div"); + const signatures = new Map(); + const baseContext = { + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: false, + openDetails: new Set(), + loadOlderTurns: vi.fn(), + renderMarkdown: (element: HTMLElement, text: string) => element.createDiv({ text }), + renderTextWithWikiLinks: (element: HTMLElement, text: string) => element.createDiv({ text }), + }; + + syncMessageRenderBlocks( + parent, + messageRenderBlocks({ + ...baseContext, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true }, + ], + }), + signatures, + ); + const userNode = parent.querySelector('[data-codex-panel-block-key="item:u1"]'); + const assistantNode = parent.querySelector('[data-codex-panel-block-key="item:a1"]'); + + syncMessageRenderBlocks( + parent, + messageRenderBlocks({ + ...baseContext, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1", markdown: true }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "userPromptSubmit: Saving jj baseline", + toolLabel: "hook", + turnId: "t1", + status: "completed", + }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1", markdown: true }, + ], + }), + signatures, + ); + + expect(parent.querySelector('[data-codex-panel-block-key="item:u1"]')).toBe(userNode); + expect(parent.querySelector('[data-codex-panel-block-key="item:a1"]')).toBe(assistantNode); + expect([...parent.children].map((element) => element.getAttribute("data-codex-panel-block-key"))).toEqual([ + "item:u1", + "activity:turn-t1-activity", + "item:a1", + ]); + expect(parent.querySelector('[data-codex-panel-block-key="activity:turn-t1-activity"] summary')?.textContent).toBe( + "Work details: hook", + ); + }); + it("does not invalidate generic tool blocks when only the workspace root changes", () => { const item = { id: "tool", @@ -1031,6 +1093,53 @@ describe("work log renderer decisions", () => { expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); }); + it("renders hook metadata when the hook is inside a completed-turn activity group", () => { + const blocks = messageRenderBlocks({ + activeThreadId: "thread", + activeTurnId: null, + historyCursor: null, + loadingHistory: false, + busy: false, + displayItems: [ + { id: "u1", kind: "message", role: "user", text: "do it", turnId: "turn", markdown: true }, + { + id: "hook-1", + kind: "hook", + role: "tool", + text: "postToolUse: Formatted 1 file.", + toolLabel: "hook", + turnId: "turn", + status: "completed", + details: [ + { + rows: [ + { key: "status", value: "completed" }, + { key: "event", value: "postToolUse" }, + { key: "message", value: "Formatted 1 file." }, + ], + }, + { title: "Hook output", body: "feedback: ok" }, + ], + }, + { id: "a1", kind: "message", role: "assistant", text: "done", turnId: "turn", markdown: true }, + ], + openDetails: new Set(["turn:turn:activity", "hook-1"]), + loadOlderTurns: vi.fn(), + renderMarkdown: (parent, text) => parent.createDiv({ text }), + renderTextWithWikiLinks: (parent, text) => parent.createDiv({ text }), + }); + + const element = blocks.find((block) => block.key === "activity:turn-turn-activity")?.render(); + + expect(element).toBeDefined(); + expect(element?.querySelector(":scope > summary")?.textContent).toBe("Work details: hook"); + 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"); + expect(element?.querySelector(".codex-panel__output-title")?.textContent).toBe("Hook output"); + expect(element?.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); + }); + it("renders task progress items as a dedicated task list", () => { const block = messageRenderBlocks({ activeThreadId: "thread",