Derive message stream display data from semantic items

This commit is contained in:
murashit 2026-06-14 09:17:32 +09:00
parent 4185cd3899
commit 45edfba92b
37 changed files with 859 additions and 606 deletions

View file

@ -9,7 +9,7 @@ import type { ThreadManagementActions } from "../threads/thread-management-actio
import type { GoalActions } from "../threads/goal-actions";
import type { HistoryController } from "../threads/history-controller";
import type { ChatInboundController } from "../protocol/inbound/controller";
import type { MessageStreamDetailSection } from "../message-stream/items";
import type { MessageStreamNoticeSection } from "../message-stream/items";
import type { ChatMessageScrollIntentState } from "../ui/message-stream/scroll-intent-state";
import type { ComposerMetaViewModel } from "../ui/composer";
import type { ChatPanelComposerShellState } from "../ui/shell-state";
@ -39,7 +39,7 @@ interface ConversationPartsContext {
};
};
runtime: {
connectionDiagnosticDetails: () => MessageStreamDetailSection[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
@ -55,7 +55,7 @@ interface ConversationPartsContext {
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamDetailSection[]) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
};
scroll: {
forceBottom: () => void;

View file

@ -1,6 +1,6 @@
import { approvalActionKind, type ApprovalAction, type PendingApproval } from "../../protocol/server-requests/approval";
import { approvalDetails, approvalResultSummary, approvalTitle } from "./approval-view";
import type { MessageStreamDetailSection, MessageStreamItem } from "../../message-stream/items";
import type { MessageStreamItem } from "../../message-stream/items";
import type { PendingUserInput } from "../../protocol/server-requests/user-input";
import { definedProp } from "../../../../utils";
@ -15,17 +15,12 @@ export function createApprovalResultItem(approval: PendingApproval, action: Appr
text: approvalResultText(approval, action),
...definedProp("turnId", approvalTurnId(approval)),
executionState: kind === "accept" || kind === "accept-session" ? "completed" : "failed",
details: [
{
title: "Approval",
rows: [
{ key: "status", value: status },
{ key: "scope", value: scope },
{ key: "request", value: approvalTitle(approval) },
...approvalDetails(approval),
],
},
],
approval: {
status,
scope,
request: approvalTitle(approval),
auditFacts: approvalDetails(approval),
},
};
}
@ -36,12 +31,11 @@ export function createUserInputResultItem(
): MessageStreamItem {
const questionCount = input.params.questions.length;
const label = questionCount === 1 ? "1 question" : `${String(questionCount)} questions`;
const details: MessageStreamDetailSection[] = input.params.questions.map((question) => ({
title: `Question: ${question.header || question.id}`,
rows: [
{ key: "Prompt", value: question.question },
...(status === "submitted" ? [{ key: "Answer", value: answers[question.id] ?? "" }] : []),
],
const questions = input.params.questions.map((question) => ({
id: question.id,
header: question.header || question.id,
question: question.question,
...(status === "submitted" ? { answer: answers[question.id] ?? "" } : {}),
}));
return {
id: `user-input-${status}-${String(input.requestId)}`,
@ -50,7 +44,7 @@ export function createUserInputResultItem(
text: status === "submitted" ? `Input submitted for ${label}.` : `Input request cancelled for ${label}.`,
...definedProp("turnId", input.params.turnId),
executionState: status === "submitted" ? "completed" : "failed",
details,
questions,
};
}

View file

@ -2,7 +2,7 @@ import type { AppServerClient } from "../../../../app-server/connection/client";
import type { CodexInput } from "../../../../domain/chat/input";
import type { ChatServerThreadActions } from "../../connection/server-actions/threads";
import type { ChatReconnectActions } from "../../connection/reconnect-actions";
import type { MessageStreamDetailSection } from "../../message-stream/items";
import type { MessageStreamNoticeSection } from "../../message-stream/items";
import type { ChatRuntimeSettingsActions } from "../../runtime/settings-actions";
import type { ChatStateStore } from "../../state/reducer";
import type { ThreadManagementActions } from "../../threads/thread-management-actions";
@ -22,10 +22,10 @@ export interface ConversationTurnActionsContext {
status: {
set: (status: string) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamDetailSection[]) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
};
runtime: {
connectionDiagnosticDetails: () => MessageStreamDetailSection[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];

View file

@ -12,7 +12,7 @@ import {
type SlashCommandName,
type SlashCommandSubcommandDefinition,
} from "../composer/slash-commands";
import type { MessageStreamDetailSection, MessageStreamDetailMetaRow } from "../../message-stream/items";
import type { MessageStreamAuditFact, MessageStreamNoticeSection } from "../../message-stream/items";
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../runtime/messages";
import { currentThreadReferenceMessage } from "./messages";
import {
@ -42,7 +42,7 @@ export interface SlashCommandExecutionContext {
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamDetailSection[]) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
requestModel: (model: string) => boolean | undefined | Promise<boolean | undefined>;
resetModelToConfig: () => boolean | undefined | Promise<boolean | undefined>;
requestReasoningEffort: (effort: ReasoningEffort) => boolean | undefined | Promise<boolean | undefined>;
@ -53,7 +53,7 @@ export interface SlashCommandExecutionContext {
setGoalStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clearGoal: (threadId: string) => Promise<boolean>;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => MessageStreamDetailSection[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
@ -371,8 +371,8 @@ function goalUsageError(message: string): string {
);
}
function goalDetails(goal: ThreadGoal): MessageStreamDetailSection[] {
const rows: MessageStreamDetailMetaRow[] = [
function goalDetails(goal: ThreadGoal): MessageStreamNoticeSection[] {
const auditFacts: MessageStreamAuditFact[] = [
{ key: "status", value: goal.status },
{ key: "objective", value: goal.objective },
{
@ -381,7 +381,7 @@ function goalDetails(goal: ThreadGoal): MessageStreamDetailSection[] {
},
{ key: "elapsed", value: formatGoalElapsed(goal.timeUsedSeconds) },
];
return [{ rows }];
return [{ auditFacts }];
}
function formatGoalElapsed(seconds: number): string {
@ -399,13 +399,13 @@ function usageError(command: SlashCommandName, message: string): string {
return `${definition.command} ${message}. Usage: ${definition.usage}`;
}
function detailsFromLines(lines: string[]): MessageStreamDetailSection[] {
function detailsFromLines(lines: string[]): MessageStreamNoticeSection[] {
const first = lines[0] ?? "";
const content = first.includes(": ") ? lines : lines.slice(1);
return [{ rows: content.map(lineToRow) }];
return [{ auditFacts: content.map(lineToRow) }];
}
function lineToRow(line: string): MessageStreamDetailMetaRow {
function lineToRow(line: string): MessageStreamAuditFact {
const separator = line.indexOf(": ");
if (separator > 0) {
return {

View file

@ -41,13 +41,10 @@ interface MessageStreamCollabAgentState {
export function agentMessageStreamItem(item: MessageStreamCollabAgentToolCall, turnId?: string): AgentMessageStreamItem {
const agents = agentStatesDisplay(item.agentsStates);
const receiverText = item.receiverThreadIds.length > 0 ? `\ntargets: ${item.receiverThreadIds.join(", ")}` : "";
const promptText = item.prompt ? `\n${item.prompt}` : "";
return {
id: item.id,
kind: "agent",
role: "tool",
text: `${agentActivitySummaryLabel(item.tool)}\nstatus: ${item.status}${receiverText}${promptText}`,
...definedProp("turnId", turnId),
sourceItemId: item.id,
tool: item.tool,
@ -62,15 +59,6 @@ export function agentMessageStreamItem(item: MessageStreamCollabAgentToolCall, t
};
}
function agentActivitySummaryLabel(tool: string): string {
if (tool === "spawnAgent") return "Spawn agent";
if (tool === "sendInput") return "Send input to agent";
if (tool === "resumeAgent") return "Resume agent";
if (tool === "wait") return "Wait for agent";
if (tool === "closeAgent") return "Close agent";
return `Agent ${tool}`;
}
function agentStatesDisplay(states: MessageStreamCollabAgentToolCall["agentsStates"]): AgentStateSummary[] {
return Object.entries(states)
.map(([threadId, state]) => ({

View file

@ -1,82 +0,0 @@
import { jsonPreview, truncate } from "../../../utils";
import type { MessageStreamDetailMetaRow, MessageStreamDetailSection } from "./items";
const TOOL_SUMMARY_LIMIT = 140;
export function compactToolSummary(label: string | null, target?: string | null, qualifier?: string | null): string {
const targetText = target?.trim();
const base = label ? (targetText ? `${label}: ${targetText}` : label) : (targetText ?? "details");
return truncate(qualifier ? `${base} (${qualifier})` : base, TOOL_SUMMARY_LIMIT);
}
export function statusQualifier(status: unknown, failure?: string | null): string | null {
if (status === "declined") return "declined";
if (status === "failed") return failure && failure.length > 0 ? failure : "failed";
return null;
}
export function failedStatusLabel(status: unknown): string | null {
if (status === "failed") return "failed";
if (status === "declined") return "declined";
return null;
}
export function metaDetail(title: string, rows: MessageStreamDetailMetaRow[]): MessageStreamDetailSection[] {
return rows.length > 0 ? [{ title, rows }] : [];
}
export function bodyDetail(title: string, body: string | null | undefined): MessageStreamDetailSection[] {
return body ? [{ title, body }] : [];
}
function jsonDetail(title: string, value: unknown): MessageStreamDetailSection[] {
return value === null || value === undefined ? [] : [{ title, body: jsonPreview(value) }];
}
export function jsonDetails(entries: [title: string, value: unknown][]): MessageStreamDetailSection[] {
return entries.flatMap(([title, value]) => jsonDetail(title, value));
}
export function jsonTargetLabel(value: unknown): string | null {
const direct = jsonTargetPrimitive(value);
if (direct) return direct;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const priorityKeys = [
"q",
"query",
"search_query",
"url",
"ref_id",
"path",
"file",
"filename",
"ticker",
"location",
"team",
"league",
"id",
"target",
"command",
];
for (const key of priorityKeys) {
const target = jsonTargetPrimitive(record[key]);
if (target) return target;
}
const firstEntry = Object.entries(record).find(([, entryValue]) => jsonTargetPrimitive(entryValue));
return firstEntry ? jsonTargetPrimitive(firstEntry[1]) : null;
}
function jsonTargetPrimitive(value: unknown): string | null {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (!Array.isArray(value)) return null;
for (const item of value) {
const target = jsonTargetLabel(item);
if (target) return target;
}
return null;
}

View file

@ -1,29 +1,21 @@
import type {
MessageStreamDetailSection,
MessageStreamFileChange,
MessageStreamFileMention,
MessageStreamItem,
CommandMessageStreamTarget,
ExecutionState,
MessageStreamPrimaryTarget,
} from "./items";
import type { HistoricalTurn } from "../../../domain/threads/history";
import type { FileUpdateChange, TurnItem } from "../../../app-server/protocol/turn";
import { definedProp, truncate } from "../../../utils";
import { definedProp } from "../../../utils";
import { referencedThreadMetadataFromPrompt, type ReferencedThreadMetadata } from "../../../domain/threads/reference";
import { turnUserItemText } from "../../../app-server/protocol/turn";
import { agentMessageStreamItem } from "./agent-items";
import { fileMentionsFromInput } from "./file-mentions";
import { normalizeProposedPlanMarkdown } from "./proposed-plan";
import { pathRelativeToRoot } from "./path-labels";
import { userMessageDisplayText } from "./user-message-text";
import {
bodyDetail,
compactToolSummary,
failedStatusLabel,
jsonDetails,
jsonTargetLabel,
metaDetail,
statusQualifier,
} from "./detail-sections";
import { failedStatusLabel, jsonTargetLabel } from "./item-labels";
type UserMessageItem = Extract<TurnItem, { type: "userMessage" }>;
type AgentMessageItem = Extract<TurnItem, { type: "agentMessage" }>;
@ -63,18 +55,22 @@ interface MessageStreamTextData extends BaseStreamItemData {
}
interface ToolMessageStreamData extends BaseStreamItemData {
text: string;
toolLabel?: string;
text?: string;
toolName?: string;
primaryTarget?: MessageStreamPrimaryTarget;
operation?: string;
failureReason?: string;
status?: string;
output?: string;
details?: MessageStreamDetailSection[];
toolCall?: Extract<MessageStreamItem, { kind: "tool" }>["toolCall"];
webSearch?: Extract<MessageStreamItem, { kind: "tool" }>["webSearch"];
imageGeneration?: Extract<MessageStreamItem, { kind: "tool" }>["imageGeneration"];
executionState?: ExecutionState;
summaryPath?: boolean;
}
interface CommandMessageStreamData extends BaseStreamItemData {
actionLabel: string;
text: string;
commandAction: "read" | "search" | "listFiles" | "command";
commandTarget: CommandMessageStreamTarget;
command: string;
cwd: string;
status: string;
@ -85,7 +81,6 @@ interface CommandMessageStreamData extends BaseStreamItemData {
}
interface FileChangeMessageStreamData extends BaseStreamItemData {
text: string;
status: string;
changes: MessageStreamFileChange[];
executionState: ExecutionState;
@ -304,17 +299,17 @@ function mcpToolCallMessageStreamItem(item: McpToolCallItem, turnId?: string): M
function mcpToolCallMessageStreamItemDataFromItem(item: McpToolCallItem): ToolMessageStreamData {
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,
text: compactToolSummary(null, target, statusQualifier(item.status, failure)),
toolLabel: name,
toolName: name,
...(target ? { primaryTarget: { kind: "value" as const, value: target } } : {}),
...(item.error?.message ? { failureReason: item.error.message } : {}),
status: item.status,
details: jsonDetails([
["Arguments JSON", item.arguments],
["Result JSON", item.result],
["Error JSON", item.error],
]),
toolCall: {
arguments: item.arguments,
result: item.result,
error: item.error,
},
output: "",
executionState: mcpToolCallExecutionState(item.status),
};
@ -325,13 +320,17 @@ function toolMessageStreamItemFromData(data: ToolMessageStreamData, turnId?: str
id: data.id,
kind: "tool",
role: "tool",
text: data.text,
...definedProp("toolLabel", data.toolLabel),
...definedProp("summaryPath", data.summaryPath),
...definedProp("text", data.text),
...definedProp("toolName", data.toolName),
...definedProp("primaryTarget", data.primaryTarget),
...definedProp("operation", data.operation),
...definedProp("failureReason", data.failureReason),
...definedProp("turnId", turnId),
sourceItemId: data.id,
...definedProp("status", data.status),
...definedProp("details", data.details),
...definedProp("toolCall", data.toolCall),
...definedProp("webSearch", data.webSearch),
...definedProp("imageGeneration", data.imageGeneration),
...definedProp("output", data.output),
...("executionState" in data ? { executionState: data.executionState } : {}),
};
@ -347,13 +346,14 @@ function dynamicToolCallMessageStreamItemDataFromItem(item: DynamicToolCallItem)
const failure = item.success === false ? "failed" : failedStatusLabel(item.status);
return {
id: item.id,
text: compactToolSummary(null, target, statusQualifier(item.status, failure)),
toolLabel: name,
toolName: name,
...(target ? { primaryTarget: { kind: "value" as const, value: target } } : {}),
...(failure ? { failureReason: failure } : {}),
status: item.status,
details: jsonDetails([
["Arguments JSON", item.arguments],
["Result JSON", item.contentItems],
]),
toolCall: {
arguments: item.arguments,
result: item.contentItems,
},
output: "",
executionState: dynamicToolCallExecutionState(item.status, item.success),
};
@ -366,9 +366,10 @@ function webSearchMessageStreamItem(item: WebSearchItem, turnId?: string): Messa
function webSearchMessageStreamItemDataFromItem(item: WebSearchItem): ToolMessageStreamData {
return {
id: item.id,
text: webSearchSummary(item),
toolLabel: "web search",
details: webSearchDetails(item),
toolName: "web search",
operation: item.action?.type ?? (item.query ? "search" : "webSearch"),
...(webSearchTarget(item) ? { primaryTarget: { kind: "value" as const, value: webSearchTarget(item) ?? "" } } : {}),
webSearch: webSearchDetails(item),
output: "",
};
}
@ -380,9 +381,8 @@ function imageViewMessageStreamItem(item: ImageViewItem, turnId?: string): Messa
function imageViewMessageStreamItemDataFromItem(item: ImageViewItem): ToolMessageStreamData {
return {
id: item.id,
text: compactToolSummary(null, item.path),
toolLabel: "imageView",
summaryPath: true,
toolName: "imageView",
primaryTarget: { kind: "path", path: item.path },
};
}
@ -392,17 +392,20 @@ function imageGenerationMessageStreamItem(item: ImageGenerationItem, turnId?: st
function imageGenerationMessageStreamItemDataFromItem(item: ImageGenerationItem): ToolMessageStreamData {
const target = item.savedPath ?? item.result;
const failureReason = failedStatusLabel(item.status);
return {
id: item.id,
text: compactToolSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))),
toolLabel: "imageGeneration",
summaryPath: Boolean(item.savedPath),
toolName: "imageGeneration",
...(target
? { primaryTarget: item.savedPath ? { kind: "path" as const, path: item.savedPath } : { kind: "value" as const, value: target } }
: {}),
...(failureReason ? { failureReason } : {}),
status: item.status,
details: [
...bodyDetail("Saved path", item.savedPath),
...bodyDetail("Revised prompt", item.revisedPrompt),
...bodyDetail("Result", item.result),
],
imageGeneration: {
...definedProp("savedPath", item.savedPath),
revisedPrompt: item.revisedPrompt,
result: item.result,
},
output: "",
executionState: imageGenerationExecutionState(item.status),
};
@ -415,8 +418,8 @@ function reviewModeMessageStreamItem(item: ReviewModeItem, turnId?: string): Mes
function reviewModeMessageStreamItemDataFromItem(item: ReviewModeItem): ToolMessageStreamData {
return {
id: item.id,
text: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode",
toolLabel: item.type,
toolName: item.type,
primaryTarget: { kind: "value", value: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode" },
output: item.review,
};
}
@ -425,16 +428,15 @@ function contextCompactionMessageStreamItem(item: ContextCompactionItem, turnId?
return contextCompactionMessageStreamItemFromData(contextCompactionMessageStreamItemDataFromItem(item), turnId);
}
function contextCompactionMessageStreamItemDataFromItem(item: ContextCompactionItem): MessageStreamTextData {
return { id: item.id, text: "Context compaction" };
function contextCompactionMessageStreamItemDataFromItem(item: ContextCompactionItem): BaseStreamItemData {
return { id: item.id };
}
function contextCompactionMessageStreamItemFromData(data: MessageStreamTextData, turnId?: string): MessageStreamItem {
function contextCompactionMessageStreamItemFromData(data: BaseStreamItemData, turnId?: string): MessageStreamItem {
return {
id: data.id,
kind: "contextCompaction",
role: "tool",
text: data.text,
...definedProp("turnId", turnId),
sourceItemId: data.id,
};
@ -447,25 +449,29 @@ function reasoningText(item: ReasoningItem): string {
.join("\n\n");
}
function commandTargetLabel(item: CommandExecutionItem): string {
function commandTarget(item: CommandExecutionItem): CommandMessageStreamTarget {
const action = representativeCommandAction(item.commandActions);
if (action?.type === "search") {
const query = commandActionValue(action.query);
const path = commandActionPathLabel(action.path, item.cwd);
if (query && path) return `${quoteInline(query)} in ${path}`;
if (query) return quoteInline(query);
if (path) return path;
const query = commandActionValue(action.query) ?? undefined;
const path = commandActionValue(action.path) ?? undefined;
return { kind: "search", ...(query ? { query } : {}), ...(path ? { path } : {}) };
}
if (action?.type === "read") return commandReadTargetLabel(action, item.cwd);
if (action?.type === "listFiles") return commandActionPathLabel(action.path, item.cwd) ?? "workspace";
return unwrapShellLoginCommand(firstCommandLine(item.command));
if (action?.type === "read") {
const path = commandActionValue(action.path) ?? undefined;
return { kind: "read", name: action.name, ...(path ? { path } : {}) };
}
if (action?.type === "listFiles") {
const path = commandActionValue(action.path) ?? undefined;
return { kind: "listFiles", ...(path ? { path } : {}) };
}
return { kind: "command", commandLine: unwrapShellLoginCommand(firstCommandLine(item.command)) };
}
function commandActionLabel(item: CommandExecutionItem): string {
function commandActionKind(item: CommandExecutionItem): "read" | "search" | "listFiles" | "command" {
const action = representativeCommandAction(item.commandActions);
if (action?.type === "read") return "read";
if (action?.type === "search") return "search";
if (action?.type === "listFiles") return "list files";
if (action?.type === "listFiles") return "listFiles";
return "command";
}
@ -484,17 +490,6 @@ function commandActionValue(value: string | null): string | null {
return trimmed !== undefined && trimmed.length > 0 ? trimmed : null;
}
function commandActionPathLabel(value: string | null, cwd: string): string | null {
const path = commandActionValue(value);
return path ? pathRelativeToWorkspace(path, cwd) : null;
}
function commandReadTargetLabel(action: Extract<CommandAction, { type: "read" }>, cwd: string): string {
const path = commandActionValue(action.path);
if (path) return pathRelativeToWorkspace(path, cwd);
return action.name;
}
function firstCommandLine(command: string): string {
return (
command
@ -519,16 +514,6 @@ function unquoteShellCommand(value: string): string {
return quote === "'" ? inner.replace(/'\\''/g, "'") : inner.replace(/\\(["\\$`])/g, "$1");
}
function quoteInline(value: string): string {
return value.includes(" ") ? JSON.stringify(value) : value;
}
function fileChangeTargetLabel(changes: MessageStreamFileChange[]): string {
if (changes.length === 0) return "no files";
if (changes.length === 1) return changes[0]?.path ?? "1 file";
return `${String(changes.length)} files`;
}
function webSearchTarget(item: WebSearchItem): string | null {
if (item.action?.type === "openPage") return item.action.url;
if (item.action?.type === "findInPage") return item.action.pattern ?? item.action.url;
@ -536,12 +521,6 @@ function webSearchTarget(item: WebSearchItem): string | null {
return item.query;
}
function webSearchSummary(item: WebSearchItem): string {
const actionType = item.action?.type ?? (item.query ? "search" : "web search");
const label = webSearchActionLabel(actionType);
return compactToolSummary(label, webSearchTarget(item));
}
function webSearchActionLabel(actionType: string): string {
if (actionType === "openPage") return "open page";
if (actionType === "findInPage") return "find in page";
@ -562,22 +541,22 @@ function webSearchQueryList(
return unique.join("; ");
}
function webSearchDetails(item: WebSearchItem): MessageStreamDetailSection[] {
const rows: { key: string; value: string }[] = [];
if (item.action) rows.push({ key: "action", value: webSearchActionLabel(item.action.type) });
function webSearchDetails(item: WebSearchItem): Extract<MessageStreamItem, { kind: "tool" }>["webSearch"] {
const details: NonNullable<Extract<MessageStreamItem, { kind: "tool" }>["webSearch"]> = {};
if (item.action) details.action = webSearchActionLabel(item.action.type);
if (item.action?.type === "search") {
const queries = webSearchQueryList(item.action.query, item.action.queries, item.query);
if (queries) rows.push({ key: "query", value: queries });
if (queries) details.query = queries;
} else if (item.action?.type === "openPage") {
if (item.action.url) rows.push({ key: "url", value: item.action.url });
if (item.action.url) details.url = item.action.url;
} else if (item.action?.type === "findInPage") {
if (item.action.pattern) rows.push({ key: "pattern", value: item.action.pattern });
if (item.action.url) rows.push({ key: "url", value: item.action.url });
if (item.action.pattern) details.pattern = item.action.pattern;
if (item.action.url) details.url = item.action.url;
} else if (item.query) {
rows.push({ key: "query", value: item.query });
details.query = item.query;
}
return metaDetail("web search", rows);
return Object.keys(details).length > 0 ? details : undefined;
}
function commandMessageStreamItem(item: CommandExecutionItem, turnId?: string): MessageStreamItem {
@ -587,15 +566,10 @@ function commandMessageStreamItem(item: CommandExecutionItem, turnId?: string):
function commandMessageStreamItemDataFromItem(item: CommandExecutionItem): CommandMessageStreamData {
const exitCode = typeof item.exitCode === "number" ? item.exitCode : undefined;
const durationMs = typeof item.durationMs === "number" ? item.durationMs : undefined;
const target = commandTargetLabel(item);
const qualifier =
typeof exitCode === "number" && exitCode !== 0
? `exit ${String(exitCode)}`
: statusQualifier(item.status, failedStatusLabel(item.status));
return {
id: item.id,
actionLabel: commandActionLabel(item),
text: compactToolSummary(null, target, qualifier),
commandAction: commandActionKind(item),
commandTarget: commandTarget(item),
command: item.command,
cwd: item.cwd,
status: item.status,
@ -611,8 +585,8 @@ function commandMessageStreamItemFromData(data: CommandMessageStreamData, turnId
id: data.id,
kind: "command",
role: "tool",
actionLabel: data.actionLabel,
text: data.text,
commandAction: data.commandAction,
commandTarget: data.commandTarget,
...definedProp("turnId", turnId),
sourceItemId: data.id,
command: data.command,
@ -631,10 +605,8 @@ function fileChangeMessageStreamItem(item: FileChangeItem, turnId?: string): Mes
function fileChangeMessageStreamItemDataFromItem(item: FileChangeItem): FileChangeMessageStreamData {
const changes = normalizeFileChanges(item.changes);
const qualifier = statusQualifier(item.status, failedStatusLabel(item.status));
return {
id: item.id,
text: compactToolSummary(null, fileChangeTargetLabel(changes), qualifier),
status: item.status,
changes,
executionState: patchApplyExecutionState(item.status),
@ -646,7 +618,6 @@ function fileChangeMessageStreamItemFromData(data: FileChangeMessageStreamData,
id: data.id,
kind: "fileChange",
role: "tool",
text: data.text,
...definedProp("turnId", turnId),
sourceItemId: data.id,
status: data.status,
@ -667,10 +638,6 @@ export function shouldSuppressLifecycleItem(item: TurnItem): boolean {
return item.type === "agentMessage" || item.type === "userMessage";
}
function pathRelativeToWorkspace(path: string, workspaceRoot?: string | null): string {
return pathRelativeToRoot(path, workspaceRoot);
}
function assertNever(_item: never): null {
return null;
}

View file

@ -23,7 +23,7 @@ export function goalChangeItem(id: string, previous: ThreadGoal | null, next: Th
kind: "goal",
role: "tool",
text: goalEventSummary(message, objective),
details: [{ rows: [{ key: "action", value: action }] }, ...(objective ? [{ title: "Objective", body: objective }] : [])],
action,
...(objective ? { objective } : {}),
};
}

View file

@ -1,5 +1,5 @@
import { definedProp } from "../../../utils";
import type { MessageStreamDetailMetaRow, MessageStreamDetailSection, HookMessageStreamItem } from "./items";
import type { HookMessageStreamItem } from "./items";
interface MessageStreamHookRun {
id: string;
@ -12,25 +12,24 @@ interface MessageStreamHookRun {
export function hookRunMessageStreamItem(run: MessageStreamHookRun, turnId: string | null, status: string): HookMessageStreamItem | null {
if (run.id.length === 0) return null;
const entries = run.entries.map((entry) => `${entry.kind}: ${entry.text}`).join("\n");
const metaRows: MessageStreamDetailMetaRow[] = [
{ key: "status", value: status },
{ key: "event", value: hookEventName(run.eventName) },
...(run.statusMessage ? [{ key: "message", value: run.statusMessage }] : []),
...(run.durationMs !== null ? [{ key: "duration", value: `${String(run.durationMs)}ms` }] : []),
];
const details: MessageStreamDetailSection[] = [{ rows: metaRows }, ...(entries ? [{ title: "Hook output", body: entries }] : [])];
const eventName = hookEventName(run.eventName);
const displayId = hookRunDisplayId(run);
return {
id: displayId,
kind: "hook",
role: "tool",
text: hookSummary(run.eventName, run.statusMessage),
toolLabel: "hook",
toolName: "hook",
operation: eventName,
...(run.statusMessage ? { primaryTarget: { kind: "value" as const, value: run.statusMessage } } : {}),
...definedProp("turnId", turnId),
sourceItemId: displayId,
status,
details,
hookRun: {
eventName,
...definedProp("statusMessage", run.statusMessage ?? undefined),
...(run.durationMs !== null ? { durationMs: `${String(run.durationMs)}ms` } : {}),
entries: run.entries,
},
output: "",
};
}
@ -43,9 +42,3 @@ function hookEventName(eventName: string | null | undefined): string {
const trimmed = eventName?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "Hook";
}
function hookSummary(eventName: string | null | undefined, statusMessage: string | null | undefined): string {
const message = statusMessage?.trim();
const event = hookEventName(eventName);
return message ? `${event}: ${message}` : event;
}

View file

@ -0,0 +1,49 @@
export function failedStatusLabel(status: unknown): string | null {
if (status === "failed") return "failed";
if (status === "declined") return "declined";
return null;
}
export function jsonTargetLabel(value: unknown): string | null {
const direct = jsonTargetPrimitive(value);
if (direct) return direct;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const priorityKeys = [
"q",
"query",
"search_query",
"url",
"ref_id",
"path",
"file",
"filename",
"ticker",
"location",
"team",
"league",
"id",
"target",
"command",
];
for (const key of priorityKeys) {
const target = jsonTargetPrimitive(record[key]);
if (target) return target;
}
const firstEntry = Object.entries(record).find(([, entryValue]) => jsonTargetPrimitive(entryValue));
return firstEntry ? jsonTargetPrimitive(firstEntry[1]) : null;
}
function jsonTargetPrimitive(value: unknown): string | null {
if (typeof value === "string" && value.trim().length > 0) return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (!Array.isArray(value)) return null;
for (const item of value) {
const target = jsonTargetLabel(item);
if (target) return target;
}
return null;
}

View file

@ -23,26 +23,100 @@ interface MessageStreamBase {
id: string;
kind: MessageStreamItemKind;
role: MessageStreamRole;
text: string;
turnId?: string;
sourceItemId?: string;
executionState?: ExecutionState;
}
export interface MessageStreamDetailMetaRow {
export type MessageStreamPrimaryTarget =
| {
kind: "path";
path: string;
}
| {
kind: "value";
value: string;
};
interface MessageStreamToolCallDetails {
arguments?: unknown;
result?: unknown;
error?: unknown;
}
interface MessageStreamWebSearchDetails {
action?: string;
query?: string;
url?: string;
pattern?: string;
}
interface MessageStreamImageGenerationDetails {
savedPath?: string;
revisedPrompt?: string | null;
result?: string;
}
interface MessageStreamHookRunDetails {
eventName: string;
statusMessage?: string;
durationMs?: string;
entries: readonly { kind: string; text: string }[];
}
export interface MessageStreamAuditFact {
key: string;
value: string;
}
export interface MessageStreamDetailSection {
export interface MessageStreamNoticeSection {
title?: string;
auditFacts?: MessageStreamAuditFact[];
body?: string;
rows?: MessageStreamDetailMetaRow[];
}
interface MessageStreamApprovalResultDetails {
status: string;
scope: "session" | "turn";
request: string;
auditFacts: MessageStreamAuditFact[];
}
export interface MessageStreamUserInputQuestionResult {
id: string;
header: string;
question: string;
answer?: string;
}
interface MessageStreamReviewResultDetails {
auditFacts: MessageStreamAuditFact[];
}
export type CommandMessageStreamTarget =
| {
kind: "read";
path?: string;
name: string;
}
| {
kind: "search";
query?: string;
path?: string;
}
| {
kind: "listFiles";
path?: string;
}
| {
kind: "command";
commandLine: string;
};
interface MessageStreamMessageBase extends MessageStreamBase {
kind: "message";
role: "user" | "assistant";
text: string;
clientId?: string;
copyText?: string;
referencedThread?: ReferencedThreadMetadata;
@ -79,38 +153,44 @@ export interface MessageStreamFileMention {
interface SystemMessageStreamItem extends MessageStreamBase {
kind: "system";
role: "system";
details?: MessageStreamDetailSection[];
text: string;
noticeSections?: MessageStreamNoticeSection[];
}
export interface GoalMessageStreamItem extends MessageStreamBase {
kind: "goal";
role: "tool";
text: string;
action: string;
objective?: string;
details?: MessageStreamDetailSection[];
}
interface UserInputResultMessageStreamItem extends MessageStreamBase {
kind: "userInputResult";
role: "tool";
details?: MessageStreamDetailSection[];
text: string;
questions: MessageStreamUserInputQuestionResult[];
}
export interface ApprovalResultMessageStreamItem extends MessageStreamBase {
kind: "approvalResult";
role: "tool";
details?: MessageStreamDetailSection[];
text: string;
approval: MessageStreamApprovalResultDetails;
}
export interface ReviewResultMessageStreamItem extends MessageStreamBase {
kind: "reviewResult";
role: "tool";
details?: MessageStreamDetailSection[];
text: string;
review?: MessageStreamReviewResultDetails;
}
export interface CommandMessageStreamItem extends MessageStreamBase {
kind: "command";
role: "tool";
actionLabel?: string;
commandAction: "read" | "search" | "listFiles" | "command";
commandTarget: CommandMessageStreamTarget;
command: string;
cwd: string;
status: string;
@ -135,24 +215,31 @@ export interface FileChangeMessageStreamItem extends MessageStreamBase {
interface ToolMessageStreamBase extends MessageStreamBase {
role: "tool";
text?: string;
activityKind?: "userSteered";
toolLabel?: string;
summaryPath?: boolean;
toolName?: string;
primaryTarget?: MessageStreamPrimaryTarget;
operation?: string;
failureReason?: string;
status?: string;
output?: string;
details?: MessageStreamDetailSection[];
}
export interface ToolCallMessageStreamItem extends ToolMessageStreamBase {
kind: "tool";
toolCall?: MessageStreamToolCallDetails;
webSearch?: MessageStreamWebSearchDetails;
imageGeneration?: MessageStreamImageGenerationDetails;
}
export interface HookMessageStreamItem extends ToolMessageStreamBase {
kind: "hook";
hookRun?: MessageStreamHookRunDetails;
}
export interface ReasoningMessageStreamItem extends ToolMessageStreamBase {
kind: "reasoning";
text: string;
}
export interface ContextCompactionMessageStreamItem extends MessageStreamBase {
@ -168,6 +255,7 @@ interface TaskProgressStep {
export interface TaskProgressMessageStreamItem extends MessageStreamBase {
kind: "taskProgress";
role: "tool";
text?: string;
explanation: string | null;
steps: TaskProgressStep[];
status: string;
@ -188,6 +276,7 @@ export interface AgentRunSummaryAgent {
export interface AgentMessageStreamItem extends MessageStreamBase {
kind: "agent";
role: "tool";
text?: string;
tool: string;
status: string;
senderThreadId: string;

View file

@ -102,7 +102,7 @@ function steeringActivityItem(item: TimelineItem, turnId: string): MessageStream
turnId,
...(item.sourceItemId ? { sourceItemId: item.sourceItemId } : {}),
activityKind: STEERING_ACTIVITY_KIND,
toolLabel: STEERING_ACTIVITY_LABEL,
toolName: STEERING_ACTIVITY_LABEL,
};
}

View file

@ -1,4 +1,4 @@
import type { MessageStreamItem, ExecutionState } from "./items";
import type { MessageStreamAuditFact, MessageStreamItem, ExecutionState } from "./items";
import { pathsRelativeToRoot } from "./path-labels";
import { permissionRows } from "./permission-rows";
@ -52,11 +52,6 @@ type AutoReviewAction =
}
| { type: "requestPermissions"; reason: string | null; permissions: Parameters<typeof permissionRows>[0] };
interface MessageStreamDetailRow {
key: string;
value: string;
}
export function createReviewResultItem(id: string, text: string): MessageStreamItem {
const parsed = parseAutomaticApprovalReviewMessage(text);
if (parsed) {
@ -66,7 +61,7 @@ export function createReviewResultItem(id: string, text: string): MessageStreamI
role: "tool",
text: parsed.summary,
executionState: autoReviewExecutionState(parsed.status),
details: [{ title: "Review", rows: parsed.rows }],
review: { auditFacts: parsed.rows },
};
}
return {
@ -98,7 +93,7 @@ export function createAutoReviewResultItem(params: AutoReviewNotification): Mess
text,
turnId: params.turnId,
executionState: completed ? autoReviewExecutionState(status) : "running",
details: [{ title: "Review", rows }],
review: { auditFacts: rows },
};
}
@ -128,7 +123,7 @@ function parseAutomaticApprovalReviewMessage(
};
}
function autoReviewActionRows(action: AutoReviewAction): MessageStreamDetailRow[] {
function autoReviewActionRows(action: AutoReviewAction): MessageStreamAuditFact[] {
if (action.type === "command") {
return [
{ key: "action", value: "command" },

View file

@ -2,7 +2,6 @@ import type { FileUpdateChange } from "../../../app-server/protocol/turn";
import type { MessageStreamItem, MessageStreamItemKind } from "./items";
import { normalizeFileChanges } from "./from-turn-items";
const STREAMED_TOOL_DETAILS_TEXT = "details";
export const STREAMED_COMMAND_RUNNING_TEXT = "Command running";
export const STREAMED_FILE_CHANGE_IN_PROGRESS_TEXT = "File change inProgress";
export const STREAMED_MCP_PROGRESS_LABEL = "mcp progress";
@ -35,8 +34,7 @@ export function streamedToolOutputMessageStreamItem(params: {
id: params.id,
kind: "tool",
role: "tool",
text: STREAMED_TOOL_DETAILS_TEXT,
toolLabel: params.fallbackLabel,
toolName: params.fallbackLabel,
turnId: params.turnId,
sourceItemId: params.id,
output: params.output,
@ -54,7 +52,6 @@ export function streamedItemOutputMessageStreamItem(params: {
id: params.id,
kind: params.kind,
role: "tool",
text: params.fallbackText,
turnId: params.turnId,
sourceItemId: params.id,
output: params.output,
@ -65,6 +62,8 @@ export function streamedItemOutputMessageStreamItem(params: {
executionState: "running",
}
: {
commandAction: "command",
commandTarget: { kind: "command", commandLine: params.fallbackText },
command: params.fallbackText,
cwd: UNKNOWN_STREAMED_COMMAND_CWD,
status: "running",
@ -83,7 +82,6 @@ export function streamingFileChangeMessageStreamItem(
id: itemId,
kind: "fileChange",
role: "tool",
text: `File change ${status}`,
turnId,
sourceItemId: itemId,
status,

View file

@ -1,4 +1,4 @@
import type { MessageStreamDetailSection, MessageStreamItem } from "./items";
import type { MessageStreamItem, MessageStreamNoticeSection } from "./items";
export function createSystemItem(id: string, text: string): MessageStreamItem {
return {
@ -9,12 +9,12 @@ export function createSystemItem(id: string, text: string): MessageStreamItem {
};
}
export function createStructuredSystemItem(id: string, text: string, details: MessageStreamDetailSection[]): MessageStreamItem {
export function createStructuredSystemItem(id: string, text: string, noticeSections: MessageStreamNoticeSection[]): MessageStreamItem {
return {
id,
kind: "system",
role: "system",
text,
details,
noticeSections,
};
}

View file

@ -26,14 +26,11 @@ export function taskProgressMessageStreamItem(
plan: readonly TaskPlanStep[],
): MessageStreamItem {
const trimmedExplanation = explanation?.trim();
const lines = plan.map((step) => `${taskProgressTextMarker(step.status)} ${step.step}`);
const body = [trimmedExplanation, ...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.length > 0 ? body : "Plan updated",
turnId,
sourceItemId: `plan-progress-${turnId}`,
explanation: trimmedExplanation !== undefined && trimmedExplanation.length > 0 ? trimmedExplanation : null,
@ -43,12 +40,6 @@ export function taskProgressMessageStreamItem(
};
}
function taskProgressTextMarker(status: TaskStepStatus): string {
if (status === "completed") return "[x]";
if (status === "inProgress") return "[>]";
return "[ ]";
}
function executionStateFromStatus(status: string, states: ExecutionStateByStatus): ExecutionState {
return states[status] ?? null;
}

View file

@ -31,16 +31,19 @@ export function timelineItemFromMessageStreamItem(
detailShape: detailShapeForMessageStreamItem(item, semanticKind),
renderSurface: renderSurfaceForMessageStreamItem(item),
lifecycle: lifecycleForMessageStreamItem(item),
text: item.text,
text: timelineTextForMessageStreamItem(item),
...definedProp("copyText", copyText),
actions,
streamItem: item,
};
if ("details" in item) return { ...base, ...definedProp("details", item.details) } as TimelineItem;
if (item.kind === "fileChange") return { ...base, changes: item.changes } as TimelineItem;
return base as TimelineItem;
}
function timelineTextForMessageStreamItem(item: MessageStreamItem): string {
return "text" in item && typeof item.text === "string" ? item.text : "";
}
export function timelineActionsForMessageStreamItem(
item: MessageStreamItem,
semanticKind = semanticKindForMessageStreamItem(item),
@ -141,12 +144,17 @@ function detailShapeForMessageStreamItem(item: MessageStreamItem, semanticKind:
return "eventSummary";
case "tool":
case "hook":
return item.details && item.details.length > 0 ? "jsonAudit" : "plainText";
return hasGenericToolDetails(item) ? "jsonAudit" : "plainText";
case "reasoning":
return "plainText";
}
}
function hasGenericToolDetails(item: Extract<MessageStreamItem, { kind: "tool" | "hook" }>): boolean {
if (item.kind === "hook") return Boolean(item.hookRun);
return Boolean(item.toolCall ?? item.webSearch ?? item.imageGeneration);
}
function renderSurfaceForMessageStreamItem(item: MessageStreamItem): TimelineRenderSurface {
if (item.kind === "message" || item.kind === "system" || item.kind === "userInputResult") return "textMessage";
if (item.kind === "taskProgress" || item.kind === "agent" || item.kind === "reasoning" || item.kind === "contextCompaction") {

View file

@ -3,7 +3,6 @@ import type {
ApprovalResultMessageStreamItem,
CommandMessageStreamItem,
ContextCompactionMessageStreamItem,
MessageStreamDetailSection,
MessageStreamFileChange,
MessageStreamItem,
ExecutionState,
@ -90,7 +89,6 @@ interface TimelineToolResultItem extends TimelineBaseItem<
> {
detailShape: "commandAudit" | "diffSet" | "jsonAudit" | "eventSummary" | "plainText";
renderSurface: "toolResult";
details?: readonly MessageStreamDetailSection[];
changes?: readonly MessageStreamFileChange[];
}

View file

@ -3,7 +3,7 @@ import type { McpServerStartupStatus } from "../../../../domain/server/diagnosti
import type { ThreadConversationSummary } from "../../../../domain/threads/transcript";
import { classifyAppServerLog } from "./app-server-logs";
import { activeTurnId, type ChatAction, type ChatState, type ChatStateStore } from "../../state/reducer";
import type { MessageStreamDetailSection } from "../../message-stream/items";
import type { MessageStreamNoticeSection } from "../../message-stream/items";
import { createStructuredSystemItem, createSystemItem } from "../../message-stream/system-items";
import { approvalResponse, type ApprovalAction, type PendingApproval } from "../server-requests/approval";
import { userInputResponse, type PendingUserInput } from "../server-requests/user-input";
@ -117,7 +117,7 @@ export class ChatInboundController {
this.dispatch({ type: "message-stream/system-item-added", item: createSystemItem(this.localItemId("system"), text) });
}
addStructuredSystemMessage(text: string, details: MessageStreamDetailSection[]): void {
addStructuredSystemMessage(text: string, details: MessageStreamNoticeSection[]): void {
this.dispatch({
type: "message-stream/system-item-added",
item: createStructuredSystemItem(this.localItemId("system"), text, details),

View file

@ -19,7 +19,7 @@ import type { ChatComposerController } from "./conversation/composer/controller"
import { createConversationParts } from "./conversation/composition";
import type { ComposerSubmitActions } from "./conversation/turns/composer-submit-actions";
import { codexPanelDisplayTitle } from "./threads/title-display";
import type { MessageStreamDetailSection, MessageStreamItem } from "./message-stream/items";
import type { MessageStreamItem, MessageStreamNoticeSection } from "./message-stream/items";
import { createStructuredSystemItem, createSystemItem } from "./message-stream/system-items";
import {
effortStatusLines as buildEffortStatusLines,
@ -146,7 +146,7 @@ interface ChatSessionSideEffects {
status: {
set: (statusText: string, phase?: ChatConnectionPhase) => void;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamDetailSection[]) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
};
composer: {
setText: (text: string) => void;
@ -931,14 +931,14 @@ export class ChatPanelSession {
});
}
private connectionDiagnosticDetails(): MessageStreamDetailSection[] {
private connectionDiagnosticDetails(): MessageStreamNoticeSection[] {
return connectionDiagnosticsModel({
state: this.state,
connected: this.parts.connection.manager.isConnected(),
configuredCommand: this.environment.plugin.settings.codexPath,
}).map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({ key: row.label, value: row.value })),
auditFacts: section.rows.map((row) => ({ key: row.label, value: row.value })),
}));
}
@ -954,7 +954,7 @@ export class ChatPanelSession {
return createSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text);
}
private structuredSystemItem(text: string, details: MessageStreamDetailSection[]): MessageStreamItem {
private structuredSystemItem(text: string, details: MessageStreamNoticeSection[]): MessageStreamItem {
return createStructuredSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text, details);
}
}

View file

@ -140,7 +140,7 @@ export function appendItemText(
): MessageStreamItem[] {
const index = items.findIndex((item) => item.sourceItemId === sourceItemId);
if (index !== -1) {
return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item));
return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${"text" in item ? item.text : ""}${delta}` } : item));
}
return [...items, streamedTextMessageStreamItem({ id: sourceItemId, kind, label, delta, turnId })];
}

View file

@ -123,7 +123,7 @@ export function messageStreamRollbackCandidate(
return {
turnId: lastTurnId,
itemId: userMessage.id,
text: userMessage.text,
text: userMessage.copyText ?? userMessage.text,
};
}
@ -302,7 +302,7 @@ function appendItemTextToMessageStream(
return updateActiveSegment(state, turnId, (segment) => {
const index = segment.indexBySourceItemId.get(sourceItemId);
if (index !== undefined) {
return replaceActiveSegmentItem(segment, index, (item) => ({ ...item, text: `${item.text}${delta}` }));
return replaceActiveSegmentItem(segment, index, (item) => ({ ...item, text: `${"text" in item ? item.text : ""}${delta}` }));
}
return appendActiveSegmentItem(segment, {
...streamedTextMessageStreamItem({

View file

@ -1,7 +1,7 @@
import type { ThreadTitleContext } from "../../thread-title/model";
import { truncate } from "../../../utils";
import { isCompletedTurnOutcomeMessage } from "../message-stream/selectors";
import type { MessageStreamItem } from "../message-stream/items";
import type { MessageStreamItem, MessageStreamMessageItem } from "../message-stream/items";
const MAX_CONTEXT_CHARS = 4_000;
@ -11,7 +11,11 @@ export function threadTitleContextFromMessageStreamItems(turnId: string, items:
turnItems.find((item) => item.kind === "message" && item.role === "user")?.text.trim() ??
precedingUnscopedTitleSeed(turnId, items) ??
"";
const assistantResponse = [...turnItems].reverse().find(isCompletedTurnOutcomeMessage)?.text.trim() ?? "";
const assistantResponse =
[...turnItems]
.reverse()
.find((item): item is MessageStreamMessageItem => item.kind === "message" && isCompletedTurnOutcomeMessage(item))
?.text.trim() ?? "";
if (!userRequest || !assistantResponse) return null;
return {
userRequest: truncateForPrompt(userRequest),

View file

@ -1,7 +1,7 @@
import { Fragment, type ComponentChild as UiNode } from "preact";
import type { MessageStreamItemAnnotations } from "../../message-stream/layout";
import type { MessageStreamDetailSection, MessageStreamItem } from "../../message-stream/items";
import type { MessageStreamItem, MessageStreamNoticeSection, MessageStreamUserInputQuestionResult } from "../../message-stream/items";
import { IconButton } from "../../../../shared/ui/components";
import type { TextItemDetailStateContext, TextItemMetadataContext } from "./context";
@ -125,7 +125,7 @@ export function TextItemDetails({
context,
}: {
itemId: string;
details: MessageStreamDetailSection[];
details: TextItemDetailSection[];
context: TextItemDetailStateContext;
}): UiNode {
return (
@ -145,25 +145,49 @@ export function TextItemDetails({
);
}
export function SystemDetails({ details }: { details: MessageStreamDetailSection[] }): UiNode {
export function SystemDetails({ details }: { details: MessageStreamNoticeSection[] }): UiNode {
return (
<>
{details.map((section, index) => (
<div key={`${section.title ?? ""}:${String(index)}`} className="codex-panel__output codex-panel__system-result-section">
{section.title ? <div className="codex-panel__output-title">{section.title}</div> : null}
<DetailSectionBody section={section} />
<DetailSectionBody section={noticeDetailSection(section)} />
</div>
))}
</>
);
}
function DetailSectionBody({ section }: { section: MessageStreamDetailSection }): UiNode {
export function userInputQuestionDetails(questions: readonly MessageStreamUserInputQuestionResult[]): TextItemDetailSection[] {
return questions.map((question) => ({
title: `Question: ${question.header}`,
facts: [
{ key: "Prompt", value: question.question },
...(question.answer !== undefined ? [{ key: "Answer", value: question.answer }] : []),
],
}));
}
interface TextItemDetailSection {
title?: string;
facts?: { key: string; value: string }[];
body?: string;
}
function noticeDetailSection(section: MessageStreamNoticeSection): TextItemDetailSection {
return {
...(section.title !== undefined ? { title: section.title } : {}),
...(section.auditFacts !== undefined ? { facts: section.auditFacts } : {}),
...(section.body !== undefined ? { body: section.body } : {}),
};
}
function DetailSectionBody({ section }: { section: TextItemDetailSection }): UiNode {
return (
<>
{section.rows && section.rows.length > 0 ? (
{section.facts && section.facts.length > 0 ? (
<dl className="codex-panel__meta-grid">
{section.rows.map((row) => (
{section.facts.map((row) => (
<Fragment key={`${row.key}\n${row.value}`}>
<dt>{row.key}</dt>
<dd>{row.value}</dd>

View file

@ -7,7 +7,15 @@ import { timelineItemFromMessageStreamItem } from "../../message-stream/timeline
import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events";
import type { TextItemContentContext, TextItemContext, TextMessageStreamItem } from "./context";
import { TextItemHeader } from "./text-item-actions";
import { AutoReviewSummaries, EditedFiles, MentionedFiles, TextItemDetails, ReferencedThread, SystemDetails } from "./text-item-metadata";
import {
AutoReviewSummaries,
EditedFiles,
MentionedFiles,
TextItemDetails,
ReferencedThread,
SystemDetails,
userInputQuestionDetails,
} from "./text-item-metadata";
const USER_MESSAGE_COLLAPSE_HEIGHT_PX = 360;
@ -25,7 +33,6 @@ function TextItem({
annotations?: MessageStreamItemAnnotations;
}): UiNode {
const collapsible = isCollapsibleUserMessage(item);
const details = "details" in item ? item.details : undefined;
const editedFiles = annotations?.editedFiles ?? [];
const autoReviewSummaries = annotations?.autoReviewSummaries ?? [];
return (
@ -44,10 +51,10 @@ function TextItem({
<MentionedFiles item={item} context={context} />
) : null}
{item.kind === "message" && autoReviewSummaries.length > 0 ? <AutoReviewSummaries summaries={autoReviewSummaries} /> : null}
{item.kind === "system" && item.details && item.details.length > 0 ? (
<SystemDetails details={item.details} />
) : details && details.length > 0 ? (
<TextItemDetails itemId={item.id} details={details} context={context} />
{item.kind === "system" && item.noticeSections && item.noticeSections.length > 0 ? (
<SystemDetails details={item.noticeSections} />
) : item.kind === "userInputResult" && item.questions.length > 0 ? (
<TextItemDetails itemId={item.id} details={userInputQuestionDetails(item.questions)} context={context} />
) : null}
</div>
);

View file

@ -1,10 +1,11 @@
import { definedProp } from "../../../../utils";
import { jsonPreview, truncate } from "../../../../utils";
import { pathRelativeToRoot } from "../../message-stream/path-labels";
import type {
ApprovalResultMessageStreamItem,
CommandMessageStreamItem,
MessageStreamDetailSection,
CommandMessageStreamTarget,
MessageStreamFileChange,
MessageStreamPrimaryTarget,
MessageStreamItem,
ExecutionState,
FileChangeMessageStreamItem,
@ -61,7 +62,14 @@ function commandToolView(item: CommandMessageStreamItem): ToolResultView {
},
...outputSection("Output", item.output),
];
return toolView(item, "codex-panel__tool-item", item.actionLabel ?? "command", `${item.id}:command-details`, details);
return toolView(
item,
"codex-panel__tool-item",
commandActionLabel(item.commandAction),
`${item.id}:command-details`,
details,
commandSummary(item),
);
}
function fileChangeToolView(item: FileChangeMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
@ -95,23 +103,17 @@ function fileChangeToolView(item: FileChangeMessageStreamItem, workspaceRoot?: s
}
function goalToolView(item: GoalMessageStreamItem): ToolResultView {
return toolView(
item,
"codex-panel__tool-item codex-panel__tool-item--goal",
"goal",
`${item.id}:goal-details`,
(item.details ?? []).flatMap(detailSection),
);
return toolView(item, "codex-panel__tool-item codex-panel__tool-item--goal", "goal", `${item.id}:goal-details`, goalDetails(item));
}
function genericToolView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): ToolResultView {
return toolView(
item,
`codex-panel__tool-item codex-panel__tool-item--${item.kind}`,
item.toolLabel ?? item.kind,
item.toolName ?? item.kind,
`${item.id}:details`,
[...(item.details ?? []).flatMap(detailSection), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
item.summaryPath ? pathRelativeToRoot(item.text, workspaceRoot) : item.text,
[...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
genericToolSummary(item, workspaceRoot),
);
}
@ -139,7 +141,7 @@ function resultToolView(
detailsKey: string,
className: string,
): ToolResultView {
return toolView(item, `codex-panel__tool-item ${className}`, label, detailsKey, (item.details ?? []).flatMap(resultDetailSection));
return toolView(item, `codex-panel__tool-item ${className}`, label, detailsKey, resultDetails(item));
}
function toolView(
@ -148,7 +150,7 @@ function toolView(
label: string,
detailsKey: string,
details: ToolResultDetailSection[],
summary = item.text,
summary = fallbackSummary(item),
): ToolResultView {
return {
className: `codex-panel__message codex-panel__message--tool ${className}`,
@ -160,28 +162,181 @@ function toolView(
};
}
function resultDetailSection(section: MessageStreamDetailSection): ToolResultDetailSection[] {
if (section.rows && section.rows.length > 0) return [{ kind: "meta", rows: section.rows }];
return detailSection(section);
}
function detailSection(section: MessageStreamDetailSection): ToolResultDetailSection[] {
if (section.rows && section.rows.length > 0) return [{ kind: "meta", ...definedProp("title", section.title), rows: section.rows }];
if (section.body) return [{ kind: "output", title: section.title ?? "Output", body: section.body }];
return [];
}
function outputSection(title: string, body: string | null | undefined): ToolResultDetailSection[] {
return body ? [{ kind: "output", title, body }] : [];
}
function fileChangeSummary(item: MessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
if (item.kind !== "fileChange") return item.text;
if (changes.length === 0) return item.text;
if (changes.length > 1) return item.text;
const relativePath = changes[0]?.displayPath;
if (!relativePath || relativePath === "(unknown)") return item.text;
const suffixMatch = /\s\(([^)]+)\)$/.exec(item.text);
const suffix = suffixMatch?.[1];
return suffix ? `${relativePath} (${suffix})` : relativePath;
function goalDetails(item: GoalMessageStreamItem): ToolResultDetailSection[] {
return [
{
kind: "meta",
rows: [{ key: "action", value: item.action }],
},
...outputSection("Objective", item.objective),
];
}
function resultDetails(item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem): ToolResultDetailSection[] {
if (item.kind === "approvalResult") {
return [
{
kind: "meta",
rows: [
{ key: "status", value: item.approval.status },
{ key: "scope", value: item.approval.scope },
{ key: "request", value: item.approval.request },
...item.approval.auditFacts,
],
},
];
}
return item.review?.auditFacts && item.review.auditFacts.length > 0 ? [{ kind: "meta", rows: item.review.auditFacts }] : [];
}
function genericToolDetails(item: ToolCallMessageStreamItem | HookMessageStreamItem): ToolResultDetailSection[] {
if (item.kind === "hook") return hookRunDetails(item);
return [...toolCallDetails(item), ...webSearchDetails(item), ...imageGenerationDetails(item)];
}
function toolCallDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
const details = item.toolCall;
if (!details) return [];
return [
...jsonOutputSection("Arguments JSON", details.arguments),
...jsonOutputSection("Result JSON", details.result),
...jsonOutputSection("Error JSON", details.error),
];
}
function webSearchDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
const details = item.webSearch;
if (!details) return [];
const rows = [
...metaRow("action", details.action),
...metaRow("query", details.query),
...metaRow("pattern", details.pattern),
...metaRow("url", details.url),
];
return rows.length > 0 ? [{ kind: "meta", title: "web search", rows }] : [];
}
function imageGenerationDetails(item: ToolCallMessageStreamItem): ToolResultDetailSection[] {
const details = item.imageGeneration;
if (!details) return [];
return [
...outputSection("Saved path", details.savedPath),
...outputSection("Revised prompt", details.revisedPrompt),
...outputSection("Result", details.result),
];
}
function hookRunDetails(item: HookMessageStreamItem): ToolResultDetailSection[] {
const details = item.hookRun;
if (!details) return [];
const rows = [
...metaRow("status", item.status),
{ key: "event", value: details.eventName },
...metaRow("message", details.statusMessage),
...metaRow("duration", details.durationMs),
];
const entries = details.entries.map((entry) => `${entry.kind}: ${entry.text}`).join("\n");
return [{ kind: "meta", rows }, ...outputSection("Hook output", entries)];
}
function jsonOutputSection(title: string, value: unknown): ToolResultDetailSection[] {
return value === null || value === undefined ? [] : outputSection(title, jsonPreview(value));
}
function metaRow(key: string, value: string | null | undefined): { key: string; value: string }[] {
return value ? [{ key, value }] : [];
}
function commandActionLabel(action: CommandMessageStreamItem["commandAction"]): string {
if (action === "read") return "read";
if (action === "search") return "search";
if (action === "listFiles") return "list files";
return "command";
}
function commandSummary(item: CommandMessageStreamItem): string {
return compactSummary(null, commandTargetSummary(item.commandTarget, item.cwd), commandQualifier(item));
}
function commandTargetSummary(target: CommandMessageStreamTarget, cwd: string): string {
if (target.kind === "read") return target.path ? pathRelativeToRoot(target.path, cwd) : target.name;
if (target.kind === "search") {
const query = target.query ? quoteInline(target.query) : null;
const path = target.path ? pathRelativeToRoot(target.path, cwd) : null;
if (query && path) return `${query} in ${path}`;
if (query) return query;
if (path) return path;
return "search";
}
if (target.kind === "listFiles") return target.path ? pathRelativeToRoot(target.path, cwd) : "workspace";
return target.commandLine;
}
function commandQualifier(item: CommandMessageStreamItem): string | null {
if (typeof item.exitCode === "number" && item.exitCode !== 0) return `exit ${String(item.exitCode)}`;
return statusQualifier(item.status, failedStatusLabel(item.status));
}
function genericToolSummary(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): string {
const target = primaryTargetSummary(item.primaryTarget, workspaceRoot);
if (!target) return item.text ?? "details";
return compactSummary(toolOperationLabel(item.operation), target, statusQualifier(item.status, item.failureReason));
}
function fileChangeSummary(item: MessageStreamItem, changes: (MessageStreamFileChange & { displayPath: string })[]): string {
if (item.kind !== "fileChange") return "text" in item && typeof item.text === "string" ? item.text : "details";
const target = fileChangeTargetSummary(changes);
return compactSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status)));
}
function fallbackSummary(item: ToolResultMessageStreamItem): string {
return "text" in item && typeof item.text === "string" ? item.text : "details";
}
function fileChangeTargetSummary(changes: (MessageStreamFileChange & { displayPath: string })[]): string {
if (changes.length === 0) return "no files";
if (changes.length === 1) return changes[0]?.displayPath ?? "1 file";
return `${String(changes.length)} files`;
}
function primaryTargetSummary(target: MessageStreamPrimaryTarget | undefined, workspaceRoot?: string | null): string | null {
if (!target) return null;
if (target.kind === "path") return pathRelativeToRoot(target.path, workspaceRoot);
return target.value;
}
function compactSummary(label: string | null, target?: string | null, qualifier?: string | null): string {
const targetText = target?.trim();
const base = label ? (targetText ? `${label}: ${targetText}` : label) : (targetText ?? "details");
return truncate(qualifier ? `${base} (${qualifier})` : base, 140);
}
function statusQualifier(status: unknown, failure?: string | null): string | null {
if (status === "declined") return "declined";
if (status === "failed") return failure && failure.length > 0 ? failure : "failed";
return null;
}
function failedStatusLabel(status: unknown): string | null {
if (status === "failed") return "failed";
if (status === "declined") return "declined";
return null;
}
function toolOperationLabel(operation: string | null | undefined): string | null {
if (!operation) return null;
if (operation === "openPage") return "open page";
if (operation === "findInPage") return "find in page";
if (operation === "search") return "search";
if (operation === "webSearch") return "web search";
if (operation === "other") return "other";
return operation;
}
function quoteInline(value: string): string {
return value.includes(" ") ? JSON.stringify(value) : value;
}

View file

@ -238,7 +238,7 @@ describe("slash commands", () => {
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith(
"Thread goal",
expect.arrayContaining([
expect.objectContaining({ rows: expect.arrayContaining([expect.objectContaining({ key: "objective", value: "Finish" })]) }),
expect.objectContaining({ auditFacts: expect.arrayContaining([expect.objectContaining({ key: "objective", value: "Finish" })]) }),
]),
);
});
@ -535,7 +535,7 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).not.toHaveBeenCalled();
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Thread status", [
{
rows: [
auditFacts: [
{ key: "Thread", value: "thread-1" },
{ key: "message", value: "Usage limits" },
{ key: "5h", value: "42%" },
@ -665,7 +665,7 @@ describe("slash commands", () => {
await executeSlashCommand("mcp", "", ctx);
expect(ctx.mcpStatusLines).toHaveBeenCalledOnce();
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("MCP servers", [{ rows: [] }]);
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("MCP servers", [{ auditFacts: [] }]);
});
it("rejects /mcp arguments", async () => {

View file

@ -11,8 +11,8 @@ describe("goal message stream items", () => {
expect(item).toMatchObject({
kind: "goal",
role: "tool",
action: "set",
objective,
details: [{ rows: [{ key: "action", value: "set" }] }, { title: "Objective", body: objective }],
});
expect(item?.text.startsWith("set: Ship the feature")).toBe(true);
expect(item?.text.length).toBeLessThan(objective.length);

View file

@ -42,8 +42,9 @@ function commandItem(id: string, text: string, turnId: string): MessageStreamIte
id,
kind: "command",
role: "tool",
text,
turnId,
commandAction: "command",
commandTarget: { kind: "command", commandLine: text },
command: text,
cwd: "/vault",
status: "completed",
@ -56,7 +57,6 @@ function fileChangeItem(id: string, turnId: string, path = "src/main.ts"): Messa
id,
kind: "fileChange",
role: "tool",
text: "File change completed",
turnId,
status: "completed",
executionState: "completed",
@ -87,7 +87,11 @@ describe("turn item conversion preserves app-server semantics", () => {
{ id: "old", items: [userMessage], itemsView: "full", status: "completed", startedAt: 1, completedAt: 2, durationMs: 1, error: null },
];
expect(messageStreamItemsFromTurns(turns).map((item) => item.text)).toEqual(["hello", "world"]);
expect(
messageStreamItemsFromTurns(turns)
.filter((item) => item.kind === "message")
.map((item) => item.text),
).toEqual(["hello", "world"]);
expect(messageStreamItemFromTurnItem(userMessage)).toMatchObject({ role: "user", copyText: "hello" });
expect(messageStreamItemFromTurnItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world" });
});
@ -186,12 +190,12 @@ describe("turn item conversion preserves app-server semantics", () => {
it("preserves reasoning text", () => {
const item: TurnItem = { type: "reasoning", id: "r1", summary: ["summary"], content: ["detail"] };
expect(messageStreamItemFromTurnItem(item)?.text).toBe("summary\n\ndetail");
expect(messageStreamItemFromTurnItem(item)).toMatchObject({ kind: "reasoning", text: "summary\n\ndetail" });
});
it("keeps empty reasoning text empty so completed placeholders can be hidden", () => {
const item: TurnItem = { type: "reasoning", id: "r1", summary: [], content: [] };
expect(messageStreamItemFromTurnItem(item)?.text).toBe("");
expect(messageStreamItemFromTurnItem(item)).toMatchObject({ kind: "reasoning", text: "" });
});
it("renders completed proposed plans as copyable assistant markdown", () => {
@ -255,7 +259,6 @@ describe("turn item conversion preserves app-server semantics", () => {
id: "plan-progress-t1",
kind: "taskProgress",
role: "tool",
text: "Working plan\n[x] Inspect code\n[>] Patch UI\n[ ] Run tests",
explanation: "Working plan",
steps: [
{ step: "Inspect code", status: "completed" },
@ -334,7 +337,7 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
text: "npm run check (exit 1)",
commandTarget: { kind: "command", commandLine: "npm run check" },
output: "stderr with many details",
executionState: "failed",
});
@ -357,8 +360,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "read",
text: "src/main.ts",
commandAction: "read",
commandTarget: { kind: "read", path: "/vault/src/main.ts", name: "main.ts" },
executionState: "completed",
});
});
@ -380,8 +383,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "read",
text: "src/main.ts",
commandAction: "read",
commandTarget: { kind: "read", path: "/vault/src/main.ts", name: "main.ts" },
command: "nl -ba src/main.ts | sed -n '1,20p'",
cwd: "/vault",
executionState: "completed",
@ -405,8 +408,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "read",
text: "/vault/src/main.ts",
commandAction: "read",
commandTarget: { kind: "read", path: "/vault/src/main.ts", name: "main.ts" },
executionState: "completed",
});
});
@ -428,8 +431,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "command",
text: "npm run check",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "npm run check" },
command: "/bin/zsh -lc 'npm run check'",
cwd: "/vault",
executionState: "completed",
@ -453,8 +456,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "search",
text: '"command target" in src/display',
commandAction: "search",
commandTarget: { kind: "search", query: "command target", path: "src/display" },
command: "rg 'command target' src/display",
cwd: "/vault",
executionState: "completed",
@ -478,8 +481,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "search",
text: "target in src/display",
commandAction: "search",
commandTarget: { kind: "search", query: "target", path: "/vault/src/display" },
executionState: "completed",
});
});
@ -501,8 +504,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "search",
text: "target in src/display",
commandAction: "search",
commandTarget: { kind: "search", query: "target", path: "C:\\Vault\\src\\display" },
executionState: "completed",
});
});
@ -524,8 +527,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "list files",
text: "src/display",
commandAction: "listFiles",
commandTarget: { kind: "listFiles", path: "src/display" },
executionState: "completed",
});
});
@ -550,8 +553,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "search",
text: "target in src",
commandAction: "search",
commandTarget: { kind: "search", query: "target", path: "src" },
executionState: "completed",
});
});
@ -573,8 +576,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
actionLabel: "list files",
text: "workspace",
commandAction: "listFiles",
commandTarget: { kind: "listFiles" },
executionState: "completed",
});
});
@ -596,7 +599,7 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "command",
text: "rg error src",
commandTarget: { kind: "command", commandLine: "rg error src" },
executionState: "completed",
});
});
@ -635,16 +638,16 @@ describe("turn item conversion preserves app-server semantics", () => {
};
expect(messageStreamItemFromTurnItem(command, "t1")).toMatchObject({
text: "npm run check",
commandTarget: { kind: "command", commandLine: "npm run check" },
executionState: "running",
});
expect(messageStreamItemFromTurnItem(fileChange, "t1")).toMatchObject({
text: "src/main.ts",
changes: [{ path: "src/main.ts" }],
executionState: "running",
});
expect(messageStreamItemFromTurnItem(tool, "t1")).toMatchObject({
text: "123",
toolLabel: "github.pull_request_read",
primaryTarget: { kind: "value", value: "123" },
toolName: "github.pull_request_read",
executionState: "running",
});
});
@ -665,12 +668,13 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "123 (Not found)",
toolLabel: "github.pull_request_read",
details: [
{ title: "Arguments JSON", body: expect.stringContaining('"id": 123') },
{ title: "Error JSON", body: expect.stringContaining("Not found") },
],
primaryTarget: { kind: "value", value: "123" },
failureReason: "Not found",
toolName: "github.pull_request_read",
toolCall: {
arguments: expect.objectContaining({ id: 123 }),
error: { message: "Not found" },
},
executionState: "failed",
});
});
@ -690,12 +694,12 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "https://example.com",
toolLabel: "web.open",
details: [
{ title: "Arguments JSON", body: expect.stringContaining("https://example.com") },
{ title: "Result JSON", body: expect.stringContaining("ok") },
],
primaryTarget: { kind: "value", value: "https://example.com" },
toolName: "web.open",
toolCall: {
arguments: { url: "https://example.com" },
result: [{ type: "inputText", text: "ok" }],
},
executionState: "completed",
});
});
@ -709,9 +713,8 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "/vault/project/assets/image.png",
toolLabel: "imageView",
summaryPath: true,
toolName: "imageView",
primaryTarget: { kind: "path", path: "/vault/project/assets/image.png" },
});
});
@ -727,15 +730,14 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "/vault/project/assets/generated.png",
toolLabel: "imageGeneration",
summaryPath: true,
toolName: "imageGeneration",
primaryTarget: { kind: "path", path: "/vault/project/assets/generated.png" },
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" },
],
imageGeneration: {
savedPath: "/vault/project/assets/generated.png",
revisedPrompt: "A precise UI mockup.",
result: "image result",
},
executionState: "completed",
});
});
@ -751,13 +753,12 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "image result",
toolLabel: "imageGeneration",
summaryPath: false,
details: [
{ title: "Revised prompt", body: "A precise UI mockup." },
{ title: "Result", body: "image result" },
],
toolName: "imageGeneration",
primaryTarget: { kind: "value", value: "image result" },
imageGeneration: {
revisedPrompt: "A precise UI mockup.",
result: "image result",
},
});
});
@ -776,8 +777,7 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "details",
toolLabel: "multi_tool_use.parallel",
toolName: "multi_tool_use.parallel",
executionState: "completed",
});
});
@ -792,17 +792,13 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "tool",
text: "search: codex app-server; obsidian codex panel; fallback query",
toolLabel: "web search",
details: [
{
title: "web search",
rows: [
{ key: "action", value: "search" },
{ key: "query", value: "codex app-server; obsidian codex panel; fallback query" },
],
},
],
toolName: "web search",
operation: "search",
primaryTarget: { kind: "value", value: "codex app-server; obsidian codex panel; fallback query" },
webSearch: {
action: "search",
query: "codex app-server; obsidian codex panel; fallback query",
},
});
});
@ -812,14 +808,14 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(entered, "t1")).toMatchObject({
kind: "tool",
text: "Entered review mode",
toolLabel: "enteredReviewMode",
toolName: "enteredReviewMode",
primaryTarget: { kind: "value", value: "Entered review mode" },
output: "Review started",
});
expect(messageStreamItemFromTurnItem(exited, "t1")).toMatchObject({
kind: "tool",
text: "Exited review mode",
toolLabel: "exitedReviewMode",
toolName: "exitedReviewMode",
primaryTarget: { kind: "value", value: "Exited review mode" },
output: "Review finished",
});
});
@ -829,7 +825,6 @@ describe("turn item conversion preserves app-server semantics", () => {
expect(messageStreamItemFromTurnItem(item, "t1")).toMatchObject({
kind: "contextCompaction",
text: "Context compaction",
turnId: "t1",
sourceItemId: "compact-1",
});
@ -845,17 +840,14 @@ describe("turn item conversion preserves app-server semantics", () => {
kind: "reviewResult",
text: "Auto-review approved: Auto-review returned a low-risk allow decision.",
executionState: "completed",
details: [
{
title: "Review",
rows: [
{ key: "status", value: "approved" },
{ key: "risk", value: "low" },
{ key: "authorization", value: "unknown" },
{ key: "message", value: "Auto-review returned a low-risk allow decision." },
],
},
],
review: {
auditFacts: [
{ key: "status", value: "approved" },
{ key: "risk", value: "low" },
{ key: "authorization", value: "unknown" },
{ key: "message", value: "Auto-review returned a low-risk allow decision." },
],
},
});
});
@ -875,16 +867,13 @@ describe("turn item conversion preserves app-server semantics", () => {
).toMatchObject({
kind: "reviewResult",
text: "Auto-review approved: apply patch (2 files)",
details: [
{
title: "Review",
rows: expect.arrayContaining([
{ key: "action", value: "apply patch" },
{ key: "cwd", value: "/vault" },
{ key: "files", value: "src/display/thread-items.ts\ntests/display-model.test.ts" },
]),
},
],
review: {
auditFacts: expect.arrayContaining([
{ key: "action", value: "apply patch" },
{ key: "cwd", value: "/vault" },
{ key: "files", value: "src/display/thread-items.ts\ntests/display-model.test.ts" },
]),
},
});
});
});
@ -933,10 +922,10 @@ describe("streaming updates target item identity without mutating history", () =
];
const updated = appendAssistantDelta(items, "a1", "t1", " world");
expect(expectPresent(updated[0]).text).toBe("hello world");
expect(expectPresent(updated[0])).toMatchObject({ text: "hello world" });
expect(expectPresent(updated[0])).toMatchObject({ copyText: "hello world" });
expect(updated).toHaveLength(2);
expect(expectPresent(items[0]).text).toBe("hello");
expect(expectPresent(items[0])).toMatchObject({ text: "hello" });
expect(updated).not.toBe(items);
});
@ -947,7 +936,8 @@ describe("streaming updates target item identity without mutating history", () =
sourceItemId: "cmd1",
kind: "command",
role: "tool",
text: "Command running",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "Command running" },
command: "npm test",
cwd: "/vault",
status: "running",
@ -962,7 +952,7 @@ describe("streaming updates target item identity without mutating history", () =
expect(withOutput[0]).toMatchObject({ output: "onetwo" });
expect(withToolOutput[0]).toMatchObject({ text: "plan: ", output: "progress" });
expect(tool.text).toBe("plan: ");
expect(command.output).toBe("one");
expect(command).toMatchObject({ output: "one" });
});
});
@ -996,7 +986,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
kind: "hook",
role: "tool",
text: "userPromptSubmit: Saving jj baseline",
toolLabel: "hook",
toolName: "hook",
turnId: "t1",
status: "completed",
},
@ -1149,7 +1139,7 @@ describe("display block grouping keeps work logs subordinate to conversation mes
summary: "Work details: steer, 2 commands",
items: [
{ id: "c1" },
{ id: "steer-activity-u2", text: "also check tests", activityKind: "userSteered", toolLabel: "steer" },
{ id: "steer-activity-u2", text: "also check tests", activityKind: "userSteered", toolName: "steer" },
{ id: "c2" },
],
});
@ -1561,7 +1551,8 @@ describe("execution state uses typed status adapters before rendered text", () =
id: "c1",
kind: "command",
role: "tool",
text: "Command",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "Command" },
command: "npm test",
cwd: "/vault",
status: "done_with_errors",
@ -1575,7 +1566,8 @@ describe("execution state uses typed status adapters before rendered text", () =
sourceItemId: "c1",
kind: "command",
role: "tool",
text: "Command running",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "Command running" },
command: "npm test",
cwd: "/vault",
status: "running",
@ -1586,7 +1578,8 @@ describe("execution state uses typed status adapters before rendered text", () =
sourceItemId: "c1",
kind: "command",
role: "tool",
text: "npm test",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "npm test" },
command: "npm test",
cwd: "/vault",
status: "completed",

View file

@ -34,12 +34,11 @@ describe("timeline item semantics", () => {
id: "patch",
kind: "fileChange",
role: "tool",
text: "File change completed",
status: "completed",
changes: [{ kind: "update", path: "src/main.ts", diff: "@@" }],
executionState: "completed",
},
{ id: "tool", kind: "tool", role: "tool", text: "tool", details: [{ rows: [{ key: "k", value: "v" }] }] },
{ id: "tool", kind: "tool", role: "tool", text: "tool", toolCall: { arguments: { k: "v" } } },
{ id: "hook", kind: "hook", role: "tool", text: "hook" },
{ id: "reasoning", kind: "reasoning", role: "tool", text: "thinking" },
]);
@ -57,11 +56,17 @@ describe("timeline item semantics", () => {
it("classifies thread and interaction events by meaning before renderer shape", () => {
const timeline = timelineItemsFromMessageStreamItems([
{ id: "goal", kind: "goal", role: "tool", text: "set: Ship it" },
{ id: "approval", kind: "approvalResult", role: "tool", text: "Approved" },
{ id: "input", kind: "userInputResult", role: "tool", text: "Answered" },
{ id: "goal", kind: "goal", role: "tool", text: "set: Ship it", action: "set" },
{
id: "approval",
kind: "approvalResult",
role: "tool",
text: "Approved",
approval: { status: "allowed", scope: "turn", request: "Approval", auditFacts: [] },
},
{ id: "input", kind: "userInputResult", role: "tool", text: "Answered", questions: [] },
{ id: "review", kind: "reviewResult", role: "tool", text: "Auto-review approved" },
{ id: "compact", kind: "contextCompaction", role: "tool", text: "Context compaction" },
{ id: "compact", kind: "contextCompaction", role: "tool" },
{ id: "system", kind: "system", role: "system", text: "Disconnected" },
]);
@ -105,7 +110,8 @@ function commandItem(id: string): MessageStreamItem {
id,
kind: "command",
role: "tool",
text: "npm test",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "npm test" },
command: "npm test",
cwd: "/vault",
status: "completed",

View file

@ -168,7 +168,7 @@ describe("ChatInboundController", () => {
{
id: "plan-progress-turn-active",
kind: "taskProgress",
text: "Plan\n[>] Inspect code",
explanation: "Plan",
steps: [{ step: "Inspect code", status: "inProgress" }],
status: "inProgress",
},
@ -259,21 +259,17 @@ describe("ChatInboundController", () => {
{
id: "hook-hook-1-1",
kind: "hook",
text: "postToolUse: Formatted 1 file.",
toolLabel: "hook",
toolName: "hook",
operation: "postToolUse",
primaryTarget: { kind: "value", value: "Formatted 1 file." },
status: "completed",
output: "",
details: [
{
rows: [
{ key: "status", value: "completed" },
{ key: "event", value: "postToolUse" },
{ key: "message", value: "Formatted 1 file." },
{ key: "duration", value: "1ms" },
],
},
{ title: "Hook output", body: "feedback: ok" },
],
hookRun: {
eventName: "postToolUse",
statusMessage: "Formatted 1 file.",
durationMs: "1ms",
entries: [{ kind: "feedback", text: "ok" }],
},
},
]);
});
@ -310,14 +306,10 @@ describe("ChatInboundController", () => {
expect(chatStateMessageStreamItems(state)[0]).toMatchObject({
kind: "hook",
details: [
{
rows: [
{ key: "status", value: "completed" },
{ key: "event", value: "postToolUse" },
],
},
],
hookRun: {
eventName: "postToolUse",
entries: [],
},
});
});
@ -436,7 +428,7 @@ describe("ChatInboundController", () => {
kind: "hook",
role: "tool",
text: "userPromptSubmit: Saving jj baseline",
toolLabel: "hook",
toolName: "hook",
status: "completed",
},
]);
@ -472,7 +464,7 @@ describe("ChatInboundController", () => {
kind: "hook",
role: "tool",
text: "userPromptSubmit: Stale hook",
toolLabel: "hook",
toolName: "hook",
status: "completed",
},
{
@ -480,7 +472,7 @@ describe("ChatInboundController", () => {
kind: "hook",
role: "tool",
text: "userPromptSubmit: Saving jj baseline",
toolLabel: "hook",
toolName: "hook",
status: "completed",
},
{ id: "local-user-1", kind: "message", messageKind: "user", role: "user", text: "hello" },
@ -613,7 +605,7 @@ describe("ChatInboundController", () => {
kind: "hook",
role: "tool",
text: "userPromptSubmit: Saving jj baseline",
toolLabel: "hook",
toolName: "hook",
status: "completed",
},
]);
@ -726,7 +718,7 @@ describe("ChatInboundController", () => {
role: "tool",
text: "Input submitted for 1 question.",
turnId: "turn-active",
details: [{ title: "Question: Scope", rows: expect.arrayContaining([{ key: "Answer", value: "Narrow" }]) }],
questions: [expect.objectContaining({ id: "scope", header: "Scope", answer: "Narrow" })],
});
});
@ -796,17 +788,12 @@ describe("ChatInboundController", () => {
text: "Allowed for this session: Need access",
turnId: "turn",
executionState: "completed",
details: [
{
title: "Approval",
rows: expect.arrayContaining([
{ key: "status", value: "allowed for session" },
{ key: "scope", value: "session" },
{ key: "request", value: "Permission approval" },
{ key: "cwd", value: "/tmp/project" },
]),
},
],
approval: {
status: "allowed for session",
scope: "session",
request: "Permission approval",
auditFacts: expect.arrayContaining([{ key: "cwd", value: "/tmp/project" }]),
},
});
});
@ -854,10 +841,10 @@ describe("ChatInboundController", () => {
-32601,
"Rejected unknown app-server request: appServer/newFutureRequest",
);
expect(chatStateMessageStreamItems(state).map((item) => item.text)).toEqual(unsupportedMessages);
expect(chatStateMessageStreamItems(state).map((item) => ("text" in item ? item.text : ""))).toEqual(unsupportedMessages);
expect(
chatStateMessageStreamItems(state)
.map((item) => item.text)
.map((item) => ("text" in item ? item.text : ""))
.join("\n"),
).not.toContain("do-not-render");
});
@ -1676,10 +1663,7 @@ describe("ChatInboundController", () => {
executionState: "completed",
});
const reviewItem = expectPresent(chatStateMessageStreamItems(state)[0]);
expect("details" in reviewItem ? reviewItem.details[0] : null).toMatchObject({
title: "Review",
rows: expect.arrayContaining([{ key: "status", value: "approved" }]),
});
expect(reviewItem).toMatchObject({ review: { auditFacts: expect.arrayContaining([{ key: "status", value: "approved" }]) } });
});
it("replaces guardian auto-review warnings when structured auto-review notifications arrive", () => {

View file

@ -33,6 +33,26 @@ describe("message stream selectors", () => {
expect(messageStreamRollbackCandidate(state)).toEqual({ turnId: "turn-3", itemId: "u3", text: "third" });
});
it("restores the raw user message text instead of rendered display text", () => {
const state = initialChatMessageStreamState([
{
id: "u1",
kind: "message",
messageKind: "user",
role: "user",
text: "Use `$obsidian-codex-panel-maintain`.",
copyText: "Use $obsidian-codex-panel-maintain.",
turnId: "turn-1",
},
]);
expect(messageStreamRollbackCandidate(state)).toEqual({
turnId: "turn-1",
itemId: "u1",
text: "Use $obsidian-codex-panel-maintain.",
});
});
it("returns null when rollback has no user message candidate", () => {
expect(messageStreamRollbackCandidate(initialChatMessageStreamState([]))).toBeNull();
expect(

View file

@ -167,8 +167,8 @@ describe("createGoalActions", () => {
kind: "goal",
role: "tool",
text: "set: Finish",
action: "set",
objective: "Finish",
details: [{ rows: [{ key: "action", value: "set" }] }, { title: "Objective", body: "Finish" }],
}),
);
expect(injectThreadItems).toHaveBeenCalledWith("thread", [

View file

@ -67,6 +67,7 @@ describe("chat thread title context", () => {
kind: "goal",
role: "tool",
text: "Goal set.",
action: "set",
objective: "ゴールから始めたスレッドを命名したい",
},
{

View file

@ -67,7 +67,7 @@ describe("message stream rendering and message actions", () => {
kind: "hook",
role: "tool",
text: "userPromptSubmit: Saving jj baseline",
toolLabel: "hook",
toolName: "hook",
turnId: "t1",
status: "completed",
},
@ -131,16 +131,13 @@ describe("message stream rendering and message actions", () => {
text: "Auto-review approved: npm test",
turnId: "turn",
executionState: "completed",
details: [
{
title: "Review",
rows: [
{ key: "status", value: "approved" },
{ key: "action", value: "apply patch" },
{ key: "files", value: "src/ui/tool-result-view.ts\nsrc/ui/message-stream.ts" },
],
},
],
review: {
auditFacts: [
{ key: "status", value: "approved" },
{ key: "action", value: "apply patch" },
{ key: "files", value: "src/ui/tool-result-view.ts\nsrc/ui/message-stream.ts" },
],
},
},
],
disclosures: emptyDisclosures(),
@ -177,9 +174,9 @@ describe("message stream rendering and message actions", () => {
kind: "system",
role: "system",
text: "Available slash commands",
details: [
noticeSections: [
{
rows: [
auditFacts: [
{ key: "/help", value: "Show available Codex slash commands." },
{ key: "/resume [thread]", value: "Resume a recent Codex thread." },
],
@ -217,8 +214,8 @@ describe("message stream rendering and message actions", () => {
kind: "goal",
role: "tool",
text: "set: Ship the feature",
action: "set",
objective: "Ship the feature",
details: [{ rows: [{ key: "action", value: "set" }] }, { title: "Objective", body: "Ship the feature" }],
},
],
disclosures: emptyDisclosures(),
@ -855,7 +852,7 @@ describe("message stream rendering and message actions", () => {
role: "tool",
text: "tool summary",
turnId: "turn",
toolLabel: "web search",
toolName: "web search",
},
],
disclosures: emptyDisclosures(),
@ -1048,7 +1045,8 @@ describe("message stream rendering and message actions", () => {
id: "cmd-1",
kind: "command",
role: "tool",
text: "npm run check (exit 1)",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "npm run check" },
turnId: "turn",
command: "npm run check",
cwd: "/vault",
@ -1086,7 +1084,8 @@ describe("message stream rendering and message actions", () => {
id: "cmd-1",
kind: "command",
role: "tool",
text: "npm run check",
commandAction: "command",
commandTarget: { kind: "command", commandLine: "npm run check" },
turnId: "turn",
command: "npm run check",
cwd: "/vault",
@ -1121,8 +1120,8 @@ describe("message stream rendering and message actions", () => {
id: "cmd-1",
kind: "command",
role: "tool",
actionLabel: "read",
text: "sed /vault/src/main.ts",
commandAction: "read",
commandTarget: { kind: "read", path: "/vault/src/main.ts", name: "main.ts" },
turnId: "turn",
command: "sed -n '1,20p' src/main.ts",
cwd: "/vault",
@ -1142,6 +1141,37 @@ describe("message stream rendering and message actions", () => {
expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["read"]);
});
it("derives command summaries from semantic command targets instead of item text", () => {
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: runningTurnLifecycle("turn"),
historyCursor: null,
loadingHistory: false,
items: [
{
id: "cmd-1",
kind: "command",
role: "tool",
commandAction: "search",
commandTarget: { kind: "search", query: "semantic target", path: "/vault/src" },
turnId: "turn",
command: "rg 'semantic target' /vault/src",
cwd: "/vault",
status: "completed",
output: "results",
},
],
disclosures: emptyDisclosures(),
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
})[0];
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe('"semantic target" in src');
});
it("renders file diffs inside a single file change details block", () => {
const block = messageStreamBlocks({
activeThreadId: "thread",
@ -1154,7 +1184,6 @@ describe("message stream rendering and message actions", () => {
id: "patch-1",
kind: "fileChange",
role: "tool",
text: "/vault/project/src/main.ts",
turnId: "turn",
status: "completed",
changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }],
@ -1179,6 +1208,34 @@ describe("message stream rendering and message actions", () => {
]);
});
it("derives file change summaries from changes and status instead of item text", () => {
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: runningTurnLifecycle("turn"),
historyCursor: null,
loadingHistory: false,
workspaceRoot: "/vault/project",
items: [
{
id: "patch-1",
kind: "fileChange",
role: "tool",
turnId: "turn",
status: "failed",
changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }],
},
],
disclosures: emptyDisclosures(),
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
})[0];
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("src/main.ts (failed)");
});
it("renders the edited files footer with an open diff action when aggregated turn diff exists", () => {
const openTurnDiff = vi.fn();
const blocks = messageStreamBlocks({
@ -1192,7 +1249,6 @@ describe("message stream rendering and message actions", () => {
id: "patch-1",
kind: "fileChange",
role: "tool",
text: "File change completed",
turnId: "turn",
status: "completed",
changes: [{ kind: "update", path: "/vault/project/src/main.ts", diff: "@@\n-old\n+new" }],
@ -1314,7 +1370,6 @@ describe("message stream rendering and message actions", () => {
id: "patch-1",
kind: "fileChange",
role: "tool",
text: "File change completed",
turnId: "turn",
status: "completed",
changes: [{ kind: "update", path: "src/main.ts", diff: "@@\n-old\n+new" }],

View file

@ -370,7 +370,7 @@ describe("pending request renderer decisions", () => {
text: "Input submitted for 1 question.",
turnId: "turn",
executionState: "completed",
details: [{ title: "Question: Scope", rows: [{ key: "Answer", value: "Narrow" }] }],
questions: [{ id: "scope", header: "Scope", question: "How broad?", answer: "Narrow" }],
},
],
disclosures: emptyDisclosures(),
@ -404,15 +404,12 @@ describe("pending request renderer decisions", () => {
text: "Allowed for this session: Need access",
turnId: "turn",
executionState: "completed",
details: [
{
title: "Approval",
rows: [
{ key: "status", value: "allowed for session" },
{ key: "scope", value: "session" },
],
},
],
approval: {
status: "allowed for session",
scope: "session",
request: "Permission approval",
auditFacts: [],
},
},
],
disclosures: emptyDisclosures(),

View file

@ -30,13 +30,13 @@ describe("work log renderer decisions", () => {
kind: "tool",
role: "tool",
text: "123",
toolLabel: "github.pull_request_read",
toolName: "github.pull_request_read",
turnId: "turn",
status: "completed",
details: [
{ title: "Arguments JSON", body: '{\n "id": 123\n}' },
{ title: "Result JSON", body: '{\n "ok": true\n}' },
],
toolCall: {
arguments: { id: 123 },
result: { ok: true },
},
},
],
disclosures: emptyDisclosures(),
@ -109,8 +109,8 @@ describe("work log renderer decisions", () => {
kind: "tool",
role: "tool",
text: "/vault/project/assets/image.png",
toolLabel: "imageView",
summaryPath: true,
toolName: "imageView",
primaryTarget: { kind: "path", path: "/vault/project/assets/image.png" },
turnId: "turn",
},
],
@ -126,6 +126,36 @@ describe("work log renderer decisions", () => {
expect(element.querySelector(".codex-panel__tool-summary")?.getAttribute("title")).toBeNull();
});
it("derives generic tool summaries from primary targets instead of item text", () => {
const block = messageStreamBlocks({
activeThreadId: "thread",
turnLifecycle: runningTurnLifecycle("turn"),
historyCursor: null,
loadingHistory: false,
workspaceRoot: "/vault/project",
items: [
{
id: "tool-1",
kind: "tool",
role: "tool",
text: "legacy text",
toolName: "web search",
operation: "search",
primaryTarget: { kind: "value", value: "codex app-server" },
turnId: "turn",
},
],
disclosures: emptyDisclosures(),
forkActionsItemId: null,
loadOlderTurns: vi.fn(),
renderMarkdown: (parent, text) => parent.createDiv({ text }),
})[0];
const element = renderMessageBlockElement(block);
expect(element.querySelector(".codex-panel__tool-summary")?.textContent).toBe("search: codex app-server");
});
it("updates path summary tools when the workspace root changes", () => {
const parent = document.createElement("div");
const item = {
@ -133,8 +163,8 @@ describe("work log renderer decisions", () => {
kind: "tool",
role: "tool",
text: "/vault/project/assets/image.png",
toolLabel: "imageView",
summaryPath: true,
toolName: "imageView",
primaryTarget: { kind: "path", path: "/vault/project/assets/image.png" },
turnId: "turn",
} as const;
const baseContext = {
@ -170,8 +200,8 @@ describe("work log renderer decisions", () => {
kind: "tool",
role: "tool",
text: "/tmp/image.png",
toolLabel: "imageView",
summaryPath: true,
toolName: "imageView",
primaryTarget: { kind: "path", path: "/tmp/image.png" },
turnId: "turn",
},
],
@ -199,7 +229,7 @@ describe("work log renderer decisions", () => {
kind: "tool",
role: "tool",
text: "/vault/project",
toolLabel: "example.tool",
toolName: "example.tool",
turnId: "turn",
},
],
@ -226,20 +256,15 @@ describe("work log renderer decisions", () => {
kind: "hook",
role: "tool",
text: "postToolUse: Formatted 1 file.",
toolLabel: "hook",
toolName: "hook",
turnId: "turn",
status: "completed",
details: [
{
rows: [
{ key: "status", value: "completed" },
{ key: "event", value: "postToolUse" },
{ key: "message", value: "Formatted 1 file." },
{ key: "duration", value: "1ms" },
],
},
{ title: "Hook output", body: "feedback: ok" },
],
hookRun: {
eventName: "postToolUse",
statusMessage: "Formatted 1 file.",
durationMs: "1ms",
entries: [{ kind: "feedback", text: "ok" }],
},
},
],
disclosures: emptyDisclosures(),
@ -274,19 +299,14 @@ describe("work log renderer decisions", () => {
kind: "hook",
role: "tool",
text: "postToolUse: Formatted 1 file.",
toolLabel: "hook",
toolName: "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" },
],
hookRun: {
eventName: "postToolUse",
statusMessage: "Formatted 1 file.",
entries: [{ kind: "feedback", text: "ok" }],
},
},
{
id: "a1",
@ -707,7 +727,6 @@ describe("work log renderer decisions", () => {
id: "compact-1",
kind: "contextCompaction",
role: "tool",
text: "Context compaction",
turnId: "turn",
};