Add regression tests for hook and display ordering

This commit is contained in:
murashit 2026-05-20 13:26:30 +09:00
parent 35d9c2ea47
commit d59a307f8a
3 changed files with 242 additions and 0 deletions

View file

@ -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" },

View file

@ -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<ServerNotification, { method: "hook/completed" }>);
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<ServerNotification, { method: "turn/started" }>);
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<ServerNotification, { method: "turn/completed" }>);
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<ServerNotification, { method: "hook/completed" }>["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 [
{

View file

@ -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<string, string>();
const baseContext = {
activeThreadId: "thread",
activeTurnId: null,
historyCursor: null,
loadingHistory: false,
busy: false,
openDetails: new Set<string>(),
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<HTMLElement>('[data-codex-panel-block-key="item:u1"]');
const assistantNode = parent.querySelector<HTMLElement>('[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",