Split display item routing

This commit is contained in:
murashit 2026-05-17 16:47:13 +09:00
parent a81b2cc3cb
commit 31281ae137
14 changed files with 353 additions and 269 deletions

View file

@ -1,6 +1,29 @@
import type { DisplayItem } from "./types";
import type { TurnPlanStep } from "../generated/app-server/v2/TurnPlanStep";
import { taskStatusMarker } from "./labels";
import { classifyExecutionState } from "./state";
export function normalizeProposedPlanMarkdown(text: string): string {
return text
.replace(/^\s*<proposed_plan>\s*\n?/i, "")
.replace(/\n?\s*<\/proposed_plan>\s*$/i, "")
.trim();
}
export function planProgressDisplayItem(turnId: string, explanation: string | null, plan: TurnPlanStep[]): DisplayItem {
const lines = plan.map((step) => `${taskStatusMarker(step.status)} ${step.step}`);
const body = [explanation?.trim(), ...lines].filter((line): line is string => Boolean(line && line.length > 0)).join("\n");
const status = plan.some((step) => step.status === "inProgress" || step.status === "pending") ? "inProgress" : "completed";
return {
id: `plan-progress-${turnId}`,
kind: "taskProgress",
role: "tool",
text: body || "Plan updated",
turnId,
itemId: `plan-progress-${turnId}`,
explanation: explanation?.trim() || null,
steps: plan.map((step) => ({ step: step.step, status: step.status })),
status,
state: classifyExecutionState({ status }),
};
}

View file

@ -1,4 +1,4 @@
import { executionState } from "./model";
import { executionState } from "./state";
import type { DisplayItem } from "./types";
export interface DisplayItemSignatureContext {

10
src/display/system.ts Normal file
View file

@ -0,0 +1,10 @@
import type { DisplayItem } from "./types";
export function createSystemItem(text: string): DisplayItem {
return {
id: `system-${Date.now()}-${Math.random().toString(36).slice(2)}`,
kind: "system",
role: "system",
text,
};
}

View file

@ -1,9 +1,7 @@
import type { DisplayDetailSection, DisplayFileChange, DisplayItem } from "./types";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import type { TurnPlanStep } from "../generated/app-server/v2/TurnPlanStep";
import { inputToText, truncate } from "../utils";
import { taskStatusMarker } from "./labels";
import { agentDisplayItem } from "./agent";
import { pathRelativeToRoot } from "./paths";
import { normalizeProposedPlanMarkdown } from "./plan";
@ -17,20 +15,22 @@ import {
metaDetail,
statusQualifier,
} from "./tool-format";
export { activeAgentRunSummary, agentDisplayItem } from "./agent";
export { displayBlocksForItems } from "./blocks";
export { normalizeProposedPlanMarkdown } from "./plan";
export { classifyExecutionState, executionState, executionStateLabel } from "./state";
export { createAutoReviewResultItem, createReviewResultItem } from "./review";
export {
appendAssistantDelta,
appendItemOutput,
appendItemText,
appendPlanDelta,
appendToolOutput,
completeReasoningItems,
upsertDisplayItem,
} from "./stream-updates";
type UserMessageItem = Extract<ThreadItem, { type: "userMessage" }>;
type AgentMessageItem = Extract<ThreadItem, { type: "agentMessage" }>;
type PlanItem = Extract<ThreadItem, { type: "plan" }>;
type HookPromptItem = Extract<ThreadItem, { type: "hookPrompt" }>;
type ReasoningItem = Extract<ThreadItem, { type: "reasoning" }>;
type CommandExecutionItem = Extract<ThreadItem, { type: "commandExecution" }>;
type CommandAction = CommandExecutionItem["commandActions"][number];
type FileChangeItem = Extract<ThreadItem, { type: "fileChange" }>;
type McpToolCallItem = Extract<ThreadItem, { type: "mcpToolCall" }>;
type DynamicToolCallItem = Extract<ThreadItem, { type: "dynamicToolCall" }>;
type WebSearchItem = Extract<ThreadItem, { type: "webSearch" }>;
type ImageViewItem = Extract<ThreadItem, { type: "imageView" }>;
type ImageGenerationItem = Extract<ThreadItem, { type: "imageGeneration" }>;
type ReviewModeItem = Extract<ThreadItem, { type: "enteredReviewMode" }> | Extract<ThreadItem, { type: "exitedReviewMode" }>;
type ContextCompactionItem = Extract<ThreadItem, { type: "contextCompaction" }>;
export function displayItemsFromTurns(turns: Turn[]): DisplayItem[] {
const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
@ -47,209 +47,225 @@ export function displayItemsFromTurns(turns: Turn[]): DisplayItem[] {
export function displayItemFromThreadItem(item: ThreadItem, turnId?: string): DisplayItem | null {
if (shouldSuppressThreadItem(item)) return null;
if (item.type === "userMessage") {
const text = inputToText(item.content);
return {
id: item.id,
kind: "message",
role: "user",
text,
copyText: text,
turnId,
itemId: item.id,
markdown: true,
};
switch (item.type) {
case "userMessage":
return userMessageDisplayItem(item, turnId);
case "agentMessage":
return agentMessageDisplayItem(item, turnId);
case "commandExecution":
return commandDisplayItem(item, turnId);
case "fileChange":
return fileChangeDisplayItem(item, turnId);
case "plan":
return planDisplayItem(item, turnId);
case "hookPrompt":
return hookPromptDisplayItem(item, turnId);
case "reasoning":
return reasoningDisplayItem(item, turnId);
case "mcpToolCall":
return mcpToolCallDisplayItem(item, turnId);
case "dynamicToolCall":
return dynamicToolCallDisplayItem(item, turnId);
case "collabAgentToolCall":
return agentDisplayItem(item, turnId);
case "webSearch":
return webSearchDisplayItem(item, turnId);
case "imageView":
return imageViewDisplayItem(item, turnId);
case "imageGeneration":
return imageGenerationDisplayItem(item, turnId);
case "enteredReviewMode":
case "exitedReviewMode":
return reviewModeDisplayItem(item, turnId);
case "contextCompaction":
return contextCompactionDisplayItem(item, turnId);
default:
return assertNever(item);
}
if (item.type === "agentMessage") {
return {
id: item.id,
kind: "message",
role: "assistant",
text: item.text,
copyText: item.text,
turnId,
itemId: item.id,
markdown: true,
};
}
if (item.type === "commandExecution") {
return commandDisplayItem(item, turnId);
}
if (item.type === "fileChange") {
return fileChangeDisplayItem(item, turnId);
}
if (item.type === "plan") {
const text = normalizeProposedPlanMarkdown(item.text);
return {
id: item.id,
kind: "message",
role: "assistant",
text,
copyText: text,
turnId,
itemId: item.id,
markdown: true,
proposedPlan: true,
};
}
if (item.type === "hookPrompt") {
return {
id: item.id,
kind: "hook",
role: "tool",
text: item.fragments.map((fragment) => fragment.text).join("\n\n") || "Hook prompt",
turnId,
itemId: item.id,
};
}
if (item.type === "reasoning") {
return {
id: item.id,
kind: "reasoning",
role: "tool",
text: reasoningText(item),
turnId,
itemId: item.id,
};
}
if (item.type === "mcpToolCall") {
const name = `${item.server}.${item.tool}`;
const target = jsonTargetLabel(item.arguments);
const failure = item.error?.message ? truncate(item.error.message, 96) : failedStatusLabel(item.status);
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, target, statusQualifier(item.status, failure)),
toolLabel: name,
turnId,
itemId: item.id,
status: item.status,
details: jsonDetails([
["Arguments JSON", item.arguments],
["Result JSON", item.result],
["Error JSON", item.error],
]),
output: "",
state: classifyExecutionState({ status: item.status }),
};
}
if (item.type === "dynamicToolCall") {
const name = `${item.namespace ? `${item.namespace}.` : ""}${item.tool}`;
const target = jsonTargetLabel(item.arguments);
const failure = item.success === false ? "failed" : failedStatusLabel(item.status);
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, target, statusQualifier(item.status, failure)),
toolLabel: name,
turnId,
itemId: item.id,
status: item.status,
details: jsonDetails([
["Arguments JSON", item.arguments],
["Result JSON", item.contentItems],
]),
output: "",
state: item.success === false ? "failed" : classifyExecutionState({ status: item.status }),
};
}
if (item.type === "collabAgentToolCall") {
return agentDisplayItem(item, turnId);
}
if (item.type === "webSearch") {
return {
id: item.id,
kind: "tool",
role: "tool",
text: webSearchSummary(item),
toolLabel: "web search",
turnId,
itemId: item.id,
details: webSearchDetails(item),
output: "",
};
}
if (item.type === "imageView") {
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, item.path),
toolLabel: "imageView",
summaryPath: true,
turnId,
itemId: item.id,
};
}
if (item.type === "imageGeneration") {
const target = item.savedPath ?? item.result ?? item.revisedPrompt ?? null;
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))),
toolLabel: "imageGeneration",
summaryPath: Boolean(item.savedPath),
turnId,
itemId: item.id,
status: item.status,
details: [
...bodyDetail("Saved path", item.savedPath),
...bodyDetail("Revised prompt", item.revisedPrompt),
...bodyDetail("Result", item.result),
],
output: "",
state: classifyExecutionState({ status: item.status }),
};
}
if (item.type === "enteredReviewMode" || item.type === "exitedReviewMode") {
return {
id: item.id,
kind: "tool",
role: "tool",
text: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode",
toolLabel: item.type,
turnId,
itemId: item.id,
output: item.review,
};
}
if (item.type === "contextCompaction") {
return {
id: item.id,
kind: "tool",
role: "tool",
text: "Context compaction",
toolLabel: "contextCompaction",
turnId,
itemId: item.id,
};
}
return null;
}
type CommandExecutionItem = Extract<ThreadItem, { type: "commandExecution" }>;
type CommandAction = CommandExecutionItem["commandActions"][number];
type FileChangeItem = Extract<ThreadItem, { type: "fileChange" }>;
type ReasoningItem = Extract<ThreadItem, { type: "reasoning" }>;
type WebSearchItem = Extract<ThreadItem, { type: "webSearch" }>;
function userMessageDisplayItem(item: UserMessageItem, turnId?: string): DisplayItem {
const text = inputToText(item.content);
return {
id: item.id,
kind: "message",
role: "user",
text,
copyText: text,
turnId,
itemId: item.id,
markdown: true,
};
}
function agentMessageDisplayItem(item: AgentMessageItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "message",
role: "assistant",
text: item.text,
copyText: item.text,
turnId,
itemId: item.id,
markdown: true,
};
}
function planDisplayItem(item: PlanItem, turnId?: string): DisplayItem {
const text = normalizeProposedPlanMarkdown(item.text);
return {
id: item.id,
kind: "message",
role: "assistant",
text,
copyText: text,
turnId,
itemId: item.id,
markdown: true,
proposedPlan: true,
};
}
function hookPromptDisplayItem(item: HookPromptItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "hook",
role: "tool",
text: item.fragments.map((fragment) => fragment.text).join("\n\n") || "Hook prompt",
turnId,
itemId: item.id,
};
}
function reasoningDisplayItem(item: ReasoningItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "reasoning",
role: "tool",
text: reasoningText(item),
turnId,
itemId: item.id,
};
}
function mcpToolCallDisplayItem(item: McpToolCallItem, turnId?: string): DisplayItem {
const name = `${item.server}.${item.tool}`;
const target = jsonTargetLabel(item.arguments);
const failure = item.error?.message ? truncate(item.error.message, 96) : failedStatusLabel(item.status);
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, target, statusQualifier(item.status, failure)),
toolLabel: name,
turnId,
itemId: item.id,
status: item.status,
details: jsonDetails([
["Arguments JSON", item.arguments],
["Result JSON", item.result],
["Error JSON", item.error],
]),
output: "",
state: classifyExecutionState({ status: item.status }),
};
}
function dynamicToolCallDisplayItem(item: DynamicToolCallItem, turnId?: string): DisplayItem {
const name = `${item.namespace ? `${item.namespace}.` : ""}${item.tool}`;
const target = jsonTargetLabel(item.arguments);
const failure = item.success === false ? "failed" : failedStatusLabel(item.status);
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, target, statusQualifier(item.status, failure)),
toolLabel: name,
turnId,
itemId: item.id,
status: item.status,
details: jsonDetails([
["Arguments JSON", item.arguments],
["Result JSON", item.contentItems],
]),
output: "",
state: item.success === false ? "failed" : classifyExecutionState({ status: item.status }),
};
}
function webSearchDisplayItem(item: WebSearchItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "tool",
role: "tool",
text: webSearchSummary(item),
toolLabel: "web search",
turnId,
itemId: item.id,
details: webSearchDetails(item),
output: "",
};
}
function imageViewDisplayItem(item: ImageViewItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, item.path),
toolLabel: "imageView",
summaryPath: true,
turnId,
itemId: item.id,
};
}
function imageGenerationDisplayItem(item: ImageGenerationItem, turnId?: string): DisplayItem {
const target = item.savedPath ?? item.result ?? item.revisedPrompt ?? null;
return {
id: item.id,
kind: "tool",
role: "tool",
text: compactToolSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))),
toolLabel: "imageGeneration",
summaryPath: Boolean(item.savedPath),
turnId,
itemId: item.id,
status: item.status,
details: [
...bodyDetail("Saved path", item.savedPath),
...bodyDetail("Revised prompt", item.revisedPrompt),
...bodyDetail("Result", item.result),
],
output: "",
state: classifyExecutionState({ status: item.status }),
};
}
function reviewModeDisplayItem(item: ReviewModeItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "tool",
role: "tool",
text: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode",
toolLabel: item.type,
turnId,
itemId: item.id,
output: item.review,
};
}
function contextCompactionDisplayItem(item: ContextCompactionItem, turnId?: string): DisplayItem {
return {
id: item.id,
kind: "tool",
role: "tool",
text: "Context compaction",
toolLabel: "contextCompaction",
turnId,
itemId: item.id,
};
}
function reasoningText(item: ReasoningItem): string {
return [...item.summary, ...item.content]
@ -430,24 +446,6 @@ export function fileChangeDisplayItem(item: FileChangeItem, turnId?: string): Di
};
}
export function planProgressDisplayItem(turnId: string, explanation: string | null, plan: TurnPlanStep[]): DisplayItem {
const lines = plan.map((step) => `${taskStatusMarker(step.status)} ${step.step}`);
const body = [explanation?.trim(), ...lines].filter((line): line is string => Boolean(line && line.length > 0)).join("\n");
const status = plan.some((step) => step.status === "inProgress" || step.status === "pending") ? "inProgress" : "completed";
return {
id: `plan-progress-${turnId}`,
kind: "taskProgress",
role: "tool",
text: body || "Plan updated",
turnId,
itemId: `plan-progress-${turnId}`,
explanation: explanation?.trim() || null,
steps: plan.map((step) => ({ step: step.step, status: step.status })),
status,
state: classifyExecutionState({ status }),
};
}
export function normalizeFileChanges(changes: unknown[]): DisplayFileChange[] {
return changes.flatMap((change) => {
if (!change || typeof change !== "object") return [];
@ -474,11 +472,6 @@ export function pathRelativeToWorkspace(path: string, workspaceRoot?: string | n
return pathRelativeToRoot(path, workspaceRoot);
}
export function createSystemItem(text: string): DisplayItem {
return {
id: `system-${Date.now()}-${Math.random().toString(36).slice(2)}`,
kind: "system",
role: "system",
text,
};
function assertNever(_item: never): null {
return null;
}

View file

@ -1,5 +1,5 @@
import { executionState } from "./model";
import { pathRelativeToRoot } from "./paths";
import { executionState } from "./state";
import type {
ApprovalResultDisplayItem,
CommandDisplayItem,

View file

@ -1,5 +1,6 @@
import { approvalResponse, type ApprovalAction, type PendingApproval } from "../approvals/model";
import { createAutoReviewResultItem, createReviewResultItem } from "../display/model";
import { planProgressDisplayItem } from "../display/plan";
import { createAutoReviewResultItem, createReviewResultItem } from "../display/review";
import {
appendAssistantDelta,
appendItemOutput,
@ -9,15 +10,14 @@ import {
completeReasoningItems,
upsertDisplayItem,
} from "../display/stream-updates";
import { createSystemItem } from "../display/system";
import {
createSystemItem,
displayItemFromThreadItem,
displayItemsFromTurns,
normalizeFileChanges,
planProgressDisplayItem,
shouldSuppressLifecycleItem,
shouldSuppressThreadItem,
} from "../display/model";
} from "../display/thread-items";
import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../display/types";
import type { RequestId } from "../generated/app-server/RequestId";
import type { ServerNotification } from "../generated/app-server/ServerNotification";

View file

@ -8,7 +8,6 @@ import {
parseReasoningEffortOverride,
reasoningEffortOverrideMessage,
} from "../runtime/settings";
export { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands";
export interface SlashCommandExecutionContext {
activeThreadId: string | null;

View file

@ -1,5 +1,5 @@
import type { AppServerClient } from "../app-server/client";
import { displayItemsFromTurns } from "../display/model";
import { displayItemsFromTurns } from "../display/thread-items";
import type { PanelState } from "./state";
export interface ThreadHistoryLoaderHost {

View file

@ -4,9 +4,10 @@ import type { AppServerClient } from "../app-server/client";
import { ConnectionManager, StaleConnectionError } from "../app-server/connection-manager";
import type { ServiceTier } from "../app-server/service-tier";
import type { ApprovalAction, PendingApproval } from "../approvals/model";
import type { SlashCommandName } from "../composer/slash-commands";
import { parseSlashCommand } from "../composer/suggestions";
import { VIEW_TYPE_CODEX_PANEL } from "../constants";
import { createSystemItem } from "../display/model";
import { createSystemItem } from "../display/system";
import type { DisplayItem } from "../display/types";
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
import {
@ -31,12 +32,12 @@ import {
runtimeSummaryLabel,
serviceTierLabel,
setRuntimeOverride,
sortedAvailableModels,
supportedReasoningEfforts,
type RuntimeSnapshot,
} from "../runtime/state";
import { sortedAvailableModels } from "../runtime/model";
import { compactContextLabel, modelOverrideMessage, reasoningEffortOverrideMessage } from "../runtime/settings";
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type SlashCommandName } from "./slash-commands";
import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult } from "./slash-commands";
import { PanelSessionController } from "./session-controller";
import { statusValue, usageLimitStatusLines } from "./status-lines";
import { ThreadHistoryLoader } from "./thread-history";

View file

@ -10,7 +10,6 @@ import { parseServiceTier, serviceTierRequestValue, type ServiceTier, type Servi
import { defaultCollaborationMode, planCollaborationMode } from "./collaboration-mode";
import { findModelByIdOrName, isReasoningEffort, supportedEffortsForModel } from "./model";
import { compactModelLabel, compactReasoningEffortLabel } from "./settings";
export { sortedAvailableModels } from "./model";
export type RuntimeOverride<T> = { kind: "default" } | { kind: "set"; value: T } | { kind: "resetPending" };

View file

@ -1,6 +1,6 @@
import { displayBlocksForItems } from "../display/blocks";
import { executionState } from "../display/model";
import { displayItemSignature } from "../display/signature";
import { executionState } from "../display/state";
import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types";
import { createIconButton, createMetaPair, createRememberedDetails } from "./components";
import { shortSignature } from "./dom";

View file

@ -1,5 +1,6 @@
import { activeAgentRunSummary, executionState } from "../display/model";
import { activeAgentRunSummary } from "../display/agent";
import { isReasoningActive } from "../display/signature";
import { executionState } from "../display/state";
import type { AgentDisplayItem, AgentRunSummary, AgentRunSummaryAgent, DisplayItem, TaskProgressDisplayItem } from "../display/types";
import { agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel, taskStatusMarker } from "../display/labels";
import type { MessageStreamContext } from "./message-stream";

View file

@ -1,22 +1,19 @@
import { describe, expect, it } from "vitest";
import { activeAgentRunSummary } from "../src/display/agent";
import { displayBlocksForItems } from "../src/display/blocks";
import {
appendAssistantDelta,
activeAgentRunSummary,
appendItemOutput,
appendItemText,
appendPlanDelta,
appendToolOutput,
createAutoReviewResultItem,
createReviewResultItem,
displayBlocksForItems,
displayItemFromThreadItem,
displayItemsFromTurns,
executionState,
normalizeProposedPlanMarkdown,
planProgressDisplayItem,
upsertDisplayItem,
} from "../src/display/model";
} from "../src/display/stream-updates";
import { normalizeProposedPlanMarkdown, planProgressDisplayItem } from "../src/display/plan";
import { createAutoReviewResultItem, createReviewResultItem } from "../src/display/review";
import { executionState } from "../src/display/state";
import { displayItemFromThreadItem, displayItemsFromTurns } from "../src/display/thread-items";
import type { DisplayItem } from "../src/display/types";
import type { ThreadItem } from "../src/generated/app-server/v2/ThreadItem";
import type { Turn } from "../src/generated/app-server/v2/Turn";
@ -76,6 +73,11 @@ describe("display model", () => {
});
});
it("suppresses legacy output thread items", () => {
expect(displayItemFromThreadItem({ type: "outputMessage", id: "out-1" } as unknown as ThreadItem)).toBeNull();
expect(displayItemFromThreadItem({ type: "toolOutputMessage", id: "tool-out-1" } as unknown as ThreadItem)).toBeNull();
});
it("preserves reasoning text", () => {
const item: ThreadItem = { type: "reasoning", id: "r1", summary: ["summary"], content: ["detail"] };
expect(displayItemFromThreadItem(item)?.text).toBe("summary\n\ndetail");
@ -362,7 +364,7 @@ describe("display model", () => {
source: "agent",
status: "completed",
commandActions: [{ type: "listFiles", command: "rg", path: "src/display" }],
aggregatedOutput: "src/display/model.ts",
aggregatedOutput: "src/display/thread-items.ts",
exitCode: 0,
durationMs: 10,
};
@ -558,6 +560,31 @@ describe("display model", () => {
});
});
it("preserves image generation status, details, and state", () => {
const item: ThreadItem = {
type: "imageGeneration",
id: "image-gen-1",
status: "completed",
revisedPrompt: "A precise UI mockup.",
result: "image result",
savedPath: "/vault/project/assets/generated.png",
};
expect(displayItemFromThreadItem(item, "t1")).toMatchObject({
kind: "tool",
text: "/vault/project/assets/generated.png",
toolLabel: "imageGeneration",
summaryPath: true,
status: "completed",
details: [
{ title: "Saved path", body: "/vault/project/assets/generated.png" },
{ title: "Revised prompt", body: "A precise UI mockup." },
{ title: "Result", body: "image result" },
],
state: "completed",
});
});
it("uses details as the summary when a tool target cannot be extracted", () => {
const item: ThreadItem = {
type: "dynamicToolCall",
@ -603,6 +630,36 @@ describe("display model", () => {
});
});
it("preserves review mode items with their review output", () => {
const entered: ThreadItem = { type: "enteredReviewMode", id: "review-entered", review: "Review started" };
const exited: ThreadItem = { type: "exitedReviewMode", id: "review-exited", review: "Review finished" };
expect(displayItemFromThreadItem(entered, "t1")).toMatchObject({
kind: "tool",
text: "Entered review mode",
toolLabel: "enteredReviewMode",
output: "Review started",
});
expect(displayItemFromThreadItem(exited, "t1")).toMatchObject({
kind: "tool",
text: "Exited review mode",
toolLabel: "exitedReviewMode",
output: "Review finished",
});
});
it("preserves context compaction items as short tool items", () => {
const item: ThreadItem = { type: "contextCompaction", id: "compact-1" };
expect(displayItemFromThreadItem(item, "t1")).toMatchObject({
kind: "tool",
text: "Context compaction",
toolLabel: "contextCompaction",
turnId: "t1",
itemId: "compact-1",
});
});
it("structures automatic approval review summary messages", () => {
expect(
createReviewResultItem(
@ -637,7 +694,7 @@ describe("display model", () => {
targetItemId: "patch-1",
decisionSource: "agent",
review: { status: "approved", riskLevel: "low", userAuthorization: "medium", rationale: "Allowed by policy." },
action: { type: "applyPatch", cwd: "/vault", files: ["/vault/src/display/model.ts", "/vault/tests/display-model.test.ts"] },
action: { type: "applyPatch", cwd: "/vault", files: ["/vault/src/display/thread-items.ts", "/vault/tests/display-model.test.ts"] },
}),
).toMatchObject({
kind: "reviewResult",
@ -648,7 +705,7 @@ describe("display model", () => {
rows: expect.arrayContaining([
{ key: "action", value: "apply patch" },
{ key: "cwd", value: "/vault" },
{ key: "files", value: "src/display/model.ts\ntests/display-model.test.ts" },
{ key: "files", value: "src/display/thread-items.ts\ntests/display-model.test.ts" },
]),
},
],

View file

@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { slashCommandHelpLines } from "../src/composer/slash-commands";
import type { Thread } from "../src/generated/app-server/v2/Thread";
import { executeSlashCommand, slashCommandHelpLines, type SlashCommandExecutionContext } from "../src/panel/slash-commands";
import { executeSlashCommand, type SlashCommandExecutionContext } from "../src/panel/slash-commands";
function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCommandExecutionContext {
return {