Normalize chat display execution state

This commit is contained in:
murashit 2026-06-01 11:06:30 +09:00
parent d9e1a79b7a
commit be7f504871
12 changed files with 67 additions and 92 deletions

View file

@ -27,7 +27,7 @@ export function agentDisplayItem(item: CollabAgentToolCallItem, turnId?: string)
model: item.model,
reasoningEffort: item.reasoningEffort,
agents,
state: collabAgentExecutionState(item.tool, item.status, item.receiverThreadIds, agents),
executionState: collabAgentExecutionState(item.tool, item.status, item.receiverThreadIds, agents),
};
}

View file

@ -25,6 +25,6 @@ export function planProgressDisplayItem(turnId: string, explanation: string | nu
explanation: trimmedExplanation !== undefined && trimmedExplanation.length > 0 ? trimmedExplanation : null,
steps: plan.map((step) => ({ step: step.step, status: step.status })),
status,
state: taskProgressExecutionState(status),
executionState: taskProgressExecutionState(status),
};
}

View file

@ -21,7 +21,7 @@ export function createReviewResultItem(id: string, text: string): DisplayItem {
role: "tool",
text: parsed.summary,
markdown: false,
state: autoReviewExecutionState(parsed.status),
executionState: autoReviewExecutionState(parsed.status),
details: [{ title: "Review", rows: parsed.rows }],
};
}
@ -55,7 +55,7 @@ export function createAutoReviewResultItem(params: AutoReviewNotification): Disp
text,
turnId: params.turnId,
markdown: false,
state: completed ? autoReviewExecutionState(status) : "running",
executionState: completed ? autoReviewExecutionState(status) : "running",
details: [{ title: "Review", rows }],
};
}

View file

@ -46,13 +46,7 @@ const AGENT_FAILED = ["interrupted", "errored", "notFound"] as const satisfies r
const AGENT_FAILED_COMPAT = ["failed"] as const;
export function executionState(item: DisplayItem): ExecutionState {
if (item.state) return item.state;
if (item.kind === "command") return commandExecutionState(item.status, item.exitCode);
if (item.kind === "fileChange") return patchApplyExecutionState(item.status);
if (item.kind === "taskProgress") return taskProgressExecutionState(item.status);
if (item.kind === "agent") return collabAgentToolCallExecutionState(item.status);
if (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning") return toolStatusExecutionState(item.status);
return null;
return item.executionState ?? null;
}
export function commandExecutionState(status: string, exitCode?: number): ExecutionState {

View file

@ -64,7 +64,7 @@ export function completeReasoningItems(items: readonly DisplayItem[], turnId: st
? {
...item,
status: "completed",
state: "completed",
executionState: "completed",
}
: item,
);
@ -191,11 +191,13 @@ export function appendItemOutput(
? {
status: "inProgress",
changes: [],
executionState: "running",
}
: {
command: fallbackText,
cwd: "(unknown)",
status: "running",
executionState: "running",
}),
},
] as DisplayItem[];

View file

@ -203,7 +203,7 @@ function mcpToolCallDisplayItem(item: McpToolCallItem, turnId?: string): Display
["Error JSON", item.error],
]),
output: "",
state: mcpToolCallExecutionState(item.status),
executionState: mcpToolCallExecutionState(item.status),
};
}
@ -225,7 +225,7 @@ function dynamicToolCallDisplayItem(item: DynamicToolCallItem, turnId?: string):
["Result JSON", item.contentItems],
]),
output: "",
state: dynamicToolCallExecutionState(item.status, item.success),
executionState: dynamicToolCallExecutionState(item.status, item.success),
};
}
@ -274,7 +274,7 @@ function imageGenerationDisplayItem(item: ImageGenerationItem, turnId?: string):
...bodyDetail("Result", item.result),
],
output: "",
state: imageGenerationExecutionState(item.status),
executionState: imageGenerationExecutionState(item.status),
};
}
@ -465,7 +465,7 @@ export function commandDisplayItem(item: CommandExecutionItem, turnId?: string):
...definedProp("exitCode", exitCode),
...definedProp("durationMs", durationMs),
output: item.aggregatedOutput ?? "",
state: commandExecutionState(item.status, exitCode),
executionState: commandExecutionState(item.status, exitCode),
};
}
@ -481,7 +481,7 @@ export function fileChangeDisplayItem(item: FileChangeItem, turnId?: string): Di
itemId: item.id,
status: item.status,
changes,
state: patchApplyExecutionState(item.status),
executionState: patchApplyExecutionState(item.status),
};
}

View file

@ -23,7 +23,7 @@ export interface DisplayBase {
text: string;
turnId?: string;
itemId?: string;
state?: ExecutionState;
executionState?: ExecutionState;
}
export interface DisplayDetailMetaRow {

View file

@ -58,7 +58,7 @@ export function createApprovalResultItem(approval: PendingApproval, action: Appr
text: approvalResultText(approval, action),
...definedProp("turnId", approvalTurnId(approval)),
markdown: false,
state: kind === "accept" || kind === "accept-session" ? "completed" : "failed",
executionState: kind === "accept" || kind === "accept-session" ? "completed" : "failed",
details: [
{
title: "Approval",
@ -94,7 +94,7 @@ export function createUserInputResultItem(
text: status === "submitted" ? `Input submitted for ${label}.` : `Input request cancelled for ${label}.`,
...definedProp("turnId", input.params.turnId),
markdown: false,
state: status === "submitted" ? "completed" : "failed",
executionState: status === "submitted" ? "completed" : "failed",
details,
};
}

View file

@ -83,7 +83,7 @@ describe("ChatController", () => {
expect(state.displayItems).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "r1", kind: "reasoning", status: "completed", state: "completed" }),
expect.objectContaining({ id: "r1", kind: "reasoning", status: "completed", executionState: "completed" }),
expect.objectContaining({ id: "a1", kind: "message", text: "answer" }),
]),
);
@ -732,7 +732,7 @@ describe("ChatController", () => {
role: "tool",
text: "Allowed for this session: Need access",
turnId: "turn",
state: "completed",
executionState: "completed",
details: [
{
title: "Approval",
@ -1304,7 +1304,7 @@ describe("ChatController", () => {
id: "review-review-1",
kind: "reviewResult",
text: "Auto-review approved: npm test",
state: "completed",
executionState: "completed",
});
const reviewItem = expectPresent(state.displayItems[0]);
expect("details" in reviewItem ? reviewItem.details[0] : null).toMatchObject({

View file

@ -13,7 +13,7 @@ import {
import { normalizeProposedPlanMarkdown, planProgressDisplayItem } from "../../../../src/features/chat/display/plan";
import { pathRelativeToRoot } from "../../../../src/features/chat/display/paths";
import { createAutoReviewResultItem, createReviewResultItem } from "../../../../src/features/chat/display/review";
import { executionState } from "../../../../src/features/chat/display/state";
import { commandExecutionState, executionState } from "../../../../src/features/chat/display/state";
import { displayItemFromThreadItem, displayItemsFromTurns } from "../../../../src/features/chat/display/thread-items";
import { referencedThreadPrompt } from "../../../../src/domain/threads/reference";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
@ -27,7 +27,17 @@ function expectPresent<T>(value: T | null | undefined): T {
}
function commandItem(id: string, text: string, turnId: string): DisplayItem {
return { id, kind: "command", role: "tool", text, turnId, command: text, cwd: "/vault", status: "completed" };
return {
id,
kind: "command",
role: "tool",
text,
turnId,
command: text,
cwd: "/vault",
status: "completed",
executionState: "completed",
};
}
function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): DisplayItem {
@ -38,6 +48,7 @@ function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): Displ
text: "File change completed",
turnId,
status: "completed",
executionState: "completed",
changes: [{ kind: "update", path, diff: "" }],
};
}
@ -225,7 +236,7 @@ describe("thread item conversion preserves app-server semantics", () => {
model: "gpt-5.5",
reasoningEffort: "high",
agents: [{ threadId: "child-thread", status: "completed", message: "Done" }],
state: "completed",
executionState: "completed",
});
});
@ -246,7 +257,7 @@ describe("thread item conversion preserves app-server semantics", () => {
expect(displayItemFromThreadItem(item, "t1")).toMatchObject({
kind: "agent",
status: "completed",
state: "completed",
executionState: "completed",
});
});
@ -269,7 +280,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
text: "npm run check (exit 1)",
output: "stderr with many details",
state: "failed",
executionState: "failed",
});
});
@ -292,7 +303,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "read",
text: "src/main.ts",
state: "completed",
executionState: "completed",
});
});
@ -317,7 +328,7 @@ describe("thread item conversion preserves app-server semantics", () => {
text: "src/main.ts",
command: "nl -ba src/main.ts | sed -n '1,20p'",
cwd: "/vault",
state: "completed",
executionState: "completed",
});
});
@ -340,7 +351,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "read",
text: "/vault/src/main.ts",
state: "completed",
executionState: "completed",
});
});
@ -365,7 +376,7 @@ describe("thread item conversion preserves app-server semantics", () => {
text: "npm run check",
command: "/bin/zsh -lc 'npm run check'",
cwd: "/vault",
state: "completed",
executionState: "completed",
});
});
@ -390,7 +401,7 @@ describe("thread item conversion preserves app-server semantics", () => {
text: '"command target" in src/display',
command: "rg 'command target' src/display",
cwd: "/vault",
state: "completed",
executionState: "completed",
});
});
@ -413,7 +424,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "search",
text: "target in src/display",
state: "completed",
executionState: "completed",
});
});
@ -436,7 +447,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "search",
text: "target in src/display",
state: "completed",
executionState: "completed",
});
});
@ -459,7 +470,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "list files",
text: "src/display",
state: "completed",
executionState: "completed",
});
});
@ -485,7 +496,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "search",
text: "target in src",
state: "completed",
executionState: "completed",
});
});
@ -508,7 +519,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "command",
actionLabel: "list files",
text: "workspace",
state: "completed",
executionState: "completed",
});
});
@ -530,7 +541,7 @@ describe("thread item conversion preserves app-server semantics", () => {
expect(displayItemFromThreadItem(item, "t1")).toMatchObject({
kind: "command",
text: "rg error src",
state: "completed",
executionState: "completed",
});
});
@ -569,16 +580,16 @@ describe("thread item conversion preserves app-server semantics", () => {
expect(displayItemFromThreadItem(command, "t1")).toMatchObject({
text: "npm run check",
state: "running",
executionState: "running",
});
expect(displayItemFromThreadItem(fileChange, "t1")).toMatchObject({
text: "src/main.ts",
state: "running",
executionState: "running",
});
expect(displayItemFromThreadItem(tool, "t1")).toMatchObject({
text: "123",
toolLabel: "github.pull_request_read",
state: "running",
executionState: "running",
});
});
@ -604,7 +615,7 @@ describe("thread item conversion preserves app-server semantics", () => {
{ title: "Arguments JSON", body: expect.stringContaining('"id": 123') },
{ title: "Error JSON", body: expect.stringContaining("Not found") },
],
state: "failed",
executionState: "failed",
});
});
@ -629,7 +640,7 @@ describe("thread item conversion preserves app-server semantics", () => {
{ title: "Arguments JSON", body: expect.stringContaining("https://example.com") },
{ title: "Result JSON", body: expect.stringContaining("ok") },
],
state: "completed",
executionState: "completed",
});
});
@ -669,7 +680,7 @@ describe("thread item conversion preserves app-server semantics", () => {
{ title: "Revised prompt", body: "A precise UI mockup." },
{ title: "Result", body: "image result" },
],
state: "completed",
executionState: "completed",
});
});
@ -711,7 +722,7 @@ describe("thread item conversion preserves app-server semantics", () => {
kind: "tool",
text: "details",
toolLabel: "multi_tool_use.parallel",
state: "completed",
executionState: "completed",
});
});
@ -778,7 +789,7 @@ describe("thread item conversion preserves app-server semantics", () => {
).toMatchObject({
kind: "reviewResult",
text: "Auto-review approved: Auto-review returned a low-risk allow decision.",
state: "completed",
executionState: "completed",
details: [
{
title: "Review",
@ -898,7 +909,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
role: "tool",
text: "Auto-review approved: npm test",
turnId: "t1",
state: "completed",
executionState: "completed",
},
{
id: "review-2",
@ -906,7 +917,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
role: "tool",
text: "Auto-review approved: npm test",
turnId: "t1",
state: "completed",
executionState: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
];
@ -926,7 +937,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
it("hides empty completed reasoning items", () => {
const items: DisplayItem[] = [
{ id: "u1", kind: "message", role: "user", text: "do it", turnId: "t1" },
{ id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed" },
{ id: "r1", kind: "reasoning", role: "tool", text: "", turnId: "t1", status: "completed", executionState: "completed" },
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
];
@ -1177,7 +1188,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
role: "tool",
text: "Auto-review approved: npm test",
turnId: "t1",
state: "completed",
executionState: "completed",
},
{
id: "review-2",
@ -1185,7 +1196,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
role: "tool",
text: "Auto-review approved: npm test",
turnId: "t1",
state: "completed",
executionState: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "done", turnId: "t1" },
];
@ -1205,7 +1216,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
role: "tool",
text: "Auto-review approved: npm test",
turnId: "t1",
state: "completed",
executionState: "completed",
},
{ id: "a1", kind: "message", role: "assistant", text: "still working", turnId: "t1" },
];
@ -1239,47 +1250,15 @@ describe("workspace path summaries stay readable without hiding audit paths", ()
describe("execution state uses typed status adapters before rendered text", () => {
it("detects failed command state", () => {
expect(
executionState({
id: "c1",
kind: "command",
role: "tool",
text: "Command",
command: "npm test",
cwd: "/vault",
status: "failed",
exitCode: 1,
}),
).toBe("failed");
expect(commandExecutionState("failed", 1)).toBe("failed");
});
it("does not infer command failure from the command text", () => {
expect(
executionState({
id: "c1",
kind: "command",
role: "tool",
text: "rg error src",
command: "rg error src",
cwd: "/vault",
status: "completed",
exitCode: 0,
}),
).toBe("completed");
expect(commandExecutionState("completed", 0)).toBe("completed");
});
it("uses typed command status before command text", () => {
expect(
executionState({
id: "c1",
kind: "command",
role: "tool",
text: "rg error src",
command: "rg error src",
cwd: "/vault",
status: "inProgress",
}),
).toBe("running");
expect(commandExecutionState("inProgress")).toBe("running");
});
it("does not infer unknown status strings with broad matching", () => {

View file

@ -218,7 +218,7 @@ describe("message stream block identity and message actions", () => {
text: "Auto-review approved: npm test",
turnId: "turn",
markdown: false,
state: "completed",
executionState: "completed",
details: [
{
title: "Review",
@ -276,7 +276,7 @@ describe("message stream block identity and message actions", () => {
cwd: "/vault",
status: "completed",
output: "ok",
state: "completed",
executionState: "completed",
},
],
openDetails: new Set(),

View file

@ -362,7 +362,7 @@ describe("pending request renderer decisions", () => {
text: "Input submitted for 1 question.",
turnId: "turn",
markdown: false,
state: "completed",
executionState: "completed",
details: [{ title: "Question: Scope", rows: [{ key: "Answer", value: "Narrow" }] }],
},
],
@ -394,7 +394,7 @@ describe("pending request renderer decisions", () => {
text: "Allowed for this session: Need access",
turnId: "turn",
markdown: false,
state: "completed",
executionState: "completed",
details: [
{
title: "Approval",