diff --git a/src/features/chat/conversation/composition.ts b/src/features/chat/conversation/composition.ts index b6f03889..141ee95e 100644 --- a/src/features/chat/conversation/composition.ts +++ b/src/features/chat/conversation/composition.ts @@ -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; diff --git a/src/features/chat/conversation/pending-requests/result-items.ts b/src/features/chat/conversation/pending-requests/result-items.ts index 859166d7..5ded3f15 100644 --- a/src/features/chat/conversation/pending-requests/result-items.ts +++ b/src/features/chat/conversation/pending-requests/result-items.ts @@ -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, }; } diff --git a/src/features/chat/conversation/turns/composition.ts b/src/features/chat/conversation/turns/composition.ts index 8992a7ae..e32c993f 100644 --- a/src/features/chat/conversation/turns/composition.ts +++ b/src/features/chat/conversation/turns/composition.ts @@ -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[]; diff --git a/src/features/chat/conversation/turns/slash-command-execution.ts b/src/features/chat/conversation/turns/slash-command-execution.ts index 4e572680..267c08da 100644 --- a/src/features/chat/conversation/turns/slash-command-execution.ts +++ b/src/features/chat/conversation/turns/slash-command-execution.ts @@ -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; toggleAutoReview: () => void | Promise; addSystemMessage: (text: string) => void; - addStructuredSystemMessage: (text: string, details: MessageStreamDetailSection[]) => void; + addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void; requestModel: (model: string) => boolean | undefined | Promise; resetModelToConfig: () => boolean | undefined | Promise; requestReasoningEffort: (effort: ReasoningEffort) => boolean | undefined | Promise; @@ -53,7 +53,7 @@ export interface SlashCommandExecutionContext { setGoalStatus: (threadId: string, status: ThreadGoalStatus) => Promise; clearGoal: (threadId: string) => Promise; statusSummaryLines: () => string[]; - connectionDiagnosticDetails: () => MessageStreamDetailSection[]; + connectionDiagnosticDetails: () => MessageStreamNoticeSection[]; mcpStatusLines: () => Promise; 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 { diff --git a/src/features/chat/message-stream/agent-items.ts b/src/features/chat/message-stream/agent-items.ts index fbde8ded..0253fae1 100644 --- a/src/features/chat/message-stream/agent-items.ts +++ b/src/features/chat/message-stream/agent-items.ts @@ -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]) => ({ diff --git a/src/features/chat/message-stream/detail-sections.ts b/src/features/chat/message-stream/detail-sections.ts deleted file mode 100644 index 68cb835c..00000000 --- a/src/features/chat/message-stream/detail-sections.ts +++ /dev/null @@ -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; - 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; -} diff --git a/src/features/chat/message-stream/from-turn-items.ts b/src/features/chat/message-stream/from-turn-items.ts index 9f9c6005..e3c1c914 100644 --- a/src/features/chat/message-stream/from-turn-items.ts +++ b/src/features/chat/message-stream/from-turn-items.ts @@ -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; type AgentMessageItem = Extract; @@ -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["toolCall"]; + webSearch?: Extract["webSearch"]; + imageGeneration?: Extract["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, 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["webSearch"] { + const details: NonNullable["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; } diff --git a/src/features/chat/message-stream/goal-items.ts b/src/features/chat/message-stream/goal-items.ts index b1251008..45d121a9 100644 --- a/src/features/chat/message-stream/goal-items.ts +++ b/src/features/chat/message-stream/goal-items.ts @@ -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 } : {}), }; } diff --git a/src/features/chat/message-stream/hook-run-items.ts b/src/features/chat/message-stream/hook-run-items.ts index 4d8e8ae2..50c3d5a9 100644 --- a/src/features/chat/message-stream/hook-run-items.ts +++ b/src/features/chat/message-stream/hook-run-items.ts @@ -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; -} diff --git a/src/features/chat/message-stream/item-labels.ts b/src/features/chat/message-stream/item-labels.ts new file mode 100644 index 00000000..d3d4d73a --- /dev/null +++ b/src/features/chat/message-stream/item-labels.ts @@ -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; + 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; +} diff --git a/src/features/chat/message-stream/items.ts b/src/features/chat/message-stream/items.ts index dd37c27c..0ed25e8e 100644 --- a/src/features/chat/message-stream/items.ts +++ b/src/features/chat/message-stream/items.ts @@ -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; diff --git a/src/features/chat/message-stream/layout.ts b/src/features/chat/message-stream/layout.ts index 00e9b7dc..b060d13e 100644 --- a/src/features/chat/message-stream/layout.ts +++ b/src/features/chat/message-stream/layout.ts @@ -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, }; } diff --git a/src/features/chat/message-stream/review-result-items.ts b/src/features/chat/message-stream/review-result-items.ts index 78d1893f..044e54ef 100644 --- a/src/features/chat/message-stream/review-result-items.ts +++ b/src/features/chat/message-stream/review-result-items.ts @@ -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[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" }, diff --git a/src/features/chat/message-stream/streaming-items.ts b/src/features/chat/message-stream/streaming-items.ts index 7676ee0a..93869a30 100644 --- a/src/features/chat/message-stream/streaming-items.ts +++ b/src/features/chat/message-stream/streaming-items.ts @@ -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, diff --git a/src/features/chat/message-stream/system-items.ts b/src/features/chat/message-stream/system-items.ts index f757fc2b..70849d8f 100644 --- a/src/features/chat/message-stream/system-items.ts +++ b/src/features/chat/message-stream/system-items.ts @@ -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, }; } diff --git a/src/features/chat/message-stream/task-progress.ts b/src/features/chat/message-stream/task-progress.ts index 1d161f85..034034b0 100644 --- a/src/features/chat/message-stream/task-progress.ts +++ b/src/features/chat/message-stream/task-progress.ts @@ -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; } diff --git a/src/features/chat/message-stream/timeline/from-items.ts b/src/features/chat/message-stream/timeline/from-items.ts index e1817f22..0d6ff6a1 100644 --- a/src/features/chat/message-stream/timeline/from-items.ts +++ b/src/features/chat/message-stream/timeline/from-items.ts @@ -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): 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") { diff --git a/src/features/chat/message-stream/timeline/types.ts b/src/features/chat/message-stream/timeline/types.ts index df468d00..ae9809e5 100644 --- a/src/features/chat/message-stream/timeline/types.ts +++ b/src/features/chat/message-stream/timeline/types.ts @@ -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[]; } diff --git a/src/features/chat/protocol/inbound/controller.ts b/src/features/chat/protocol/inbound/controller.ts index 8af9784c..0ed111c3 100644 --- a/src/features/chat/protocol/inbound/controller.ts +++ b/src/features/chat/protocol/inbound/controller.ts @@ -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), diff --git a/src/features/chat/session.ts b/src/features/chat/session.ts index 0e48f7b7..a05ffd83 100644 --- a/src/features/chat/session.ts +++ b/src/features/chat/session.ts @@ -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); } } diff --git a/src/features/chat/state/message-stream-updates.ts b/src/features/chat/state/message-stream-updates.ts index 91a6cdb3..702400f7 100644 --- a/src/features/chat/state/message-stream-updates.ts +++ b/src/features/chat/state/message-stream-updates.ts @@ -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 })]; } diff --git a/src/features/chat/state/message-stream.ts b/src/features/chat/state/message-stream.ts index 8c08b3c0..c3b26b1e 100644 --- a/src/features/chat/state/message-stream.ts +++ b/src/features/chat/state/message-stream.ts @@ -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({ diff --git a/src/features/chat/threads/title-context.ts b/src/features/chat/threads/title-context.ts index bf00a247..b6397b47 100644 --- a/src/features/chat/threads/title-context.ts +++ b/src/features/chat/threads/title-context.ts @@ -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), diff --git a/src/features/chat/ui/message-stream/text-item-metadata.tsx b/src/features/chat/ui/message-stream/text-item-metadata.tsx index 35d5390e..49e30516 100644 --- a/src/features/chat/ui/message-stream/text-item-metadata.tsx +++ b/src/features/chat/ui/message-stream/text-item-metadata.tsx @@ -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) => (
{section.title ?
{section.title}
: null} - +
))} ); } -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 ? (
- {section.rows.map((row) => ( + {section.facts.map((row) => (
{row.key}
{row.value}
diff --git a/src/features/chat/ui/message-stream/text-item.tsx b/src/features/chat/ui/message-stream/text-item.tsx index 4573862c..4dcc6aa3 100644 --- a/src/features/chat/ui/message-stream/text-item.tsx +++ b/src/features/chat/ui/message-stream/text-item.tsx @@ -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({ ) : null} {item.kind === "message" && autoReviewSummaries.length > 0 ? : null} - {item.kind === "system" && item.details && item.details.length > 0 ? ( - - ) : details && details.length > 0 ? ( - + {item.kind === "system" && item.noticeSections && item.noticeSections.length > 0 ? ( + + ) : item.kind === "userInputResult" && item.questions.length > 0 ? ( + ) : null} ); diff --git a/src/features/chat/ui/message-stream/tool-result-view-model.ts b/src/features/chat/ui/message-stream/tool-result-view-model.ts index acf5aa94..b8d4c781 100644 --- a/src/features/chat/ui/message-stream/tool-result-view-model.ts +++ b/src/features/chat/ui/message-stream/tool-result-view-model.ts @@ -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; } diff --git a/tests/features/chat/conversation/turns/slash-command-execution.test.ts b/tests/features/chat/conversation/turns/slash-command-execution.test.ts index 41bb3c08..375effb7 100644 --- a/tests/features/chat/conversation/turns/slash-command-execution.test.ts +++ b/tests/features/chat/conversation/turns/slash-command-execution.test.ts @@ -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 () => { diff --git a/tests/features/chat/message-stream/goal-items.test.ts b/tests/features/chat/message-stream/goal-items.test.ts index 018167ae..1484f029 100644 --- a/tests/features/chat/message-stream/goal-items.test.ts +++ b/tests/features/chat/message-stream/goal-items.test.ts @@ -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); diff --git a/tests/features/chat/message-stream/model.test.ts b/tests/features/chat/message-stream/model.test.ts index 41d78ac8..1b8d06e4 100644 --- a/tests/features/chat/message-stream/model.test.ts +++ b/tests/features/chat/message-stream/model.test.ts @@ -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", diff --git a/tests/features/chat/message-stream/timeline/timeline.test.ts b/tests/features/chat/message-stream/timeline/timeline.test.ts index e894be86..52fc2b1d 100644 --- a/tests/features/chat/message-stream/timeline/timeline.test.ts +++ b/tests/features/chat/message-stream/timeline/timeline.test.ts @@ -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", diff --git a/tests/features/chat/protocol/inbound/controller.test.ts b/tests/features/chat/protocol/inbound/controller.test.ts index e57672f7..a0ccb91a 100644 --- a/tests/features/chat/protocol/inbound/controller.test.ts +++ b/tests/features/chat/protocol/inbound/controller.test.ts @@ -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", () => { diff --git a/tests/features/chat/state/message-stream-selectors.test.ts b/tests/features/chat/state/message-stream-selectors.test.ts index e9196d6f..18c48dc8 100644 --- a/tests/features/chat/state/message-stream-selectors.test.ts +++ b/tests/features/chat/state/message-stream-selectors.test.ts @@ -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( diff --git a/tests/features/chat/threads/goal-actions.test.ts b/tests/features/chat/threads/goal-actions.test.ts index 2e82c5c5..a8cda3fe 100644 --- a/tests/features/chat/threads/goal-actions.test.ts +++ b/tests/features/chat/threads/goal-actions.test.ts @@ -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", [ diff --git a/tests/features/chat/threads/title-context.test.ts b/tests/features/chat/threads/title-context.test.ts index c94824fc..c862c87d 100644 --- a/tests/features/chat/threads/title-context.test.ts +++ b/tests/features/chat/threads/title-context.test.ts @@ -67,6 +67,7 @@ describe("chat thread title context", () => { kind: "goal", role: "tool", text: "Goal set.", + action: "set", objective: "ゴールから始めたスレッドを命名したい", }, { diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 51db2019..c90d95b1 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -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" }], diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index 62a1f42e..fba38642 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -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(), diff --git a/tests/features/chat/ui/message-stream/work-log.test.tsx b/tests/features/chat/ui/message-stream/work-log.test.tsx index 08fc6d6e..1dff0595 100644 --- a/tests/features/chat/ui/message-stream/work-log.test.tsx +++ b/tests/features/chat/ui/message-stream/work-log.test.tsx @@ -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", };