From 8f7712ed74b0e6387ddf47373156786564decf56 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 27 Apr 2026 23:50:23 +0300 Subject: [PATCH] fix(team): improve opencode task log rendering --- runtime.lock.json | 12 +- .../BoardTaskActivityDetailService.ts | 23 +- .../stream/BoardTaskLogStreamService.ts | 28 +- .../components/chat/items/LinkedToolItem.tsx | 11 +- .../items/linkedTool/DefaultToolViewer.tsx | 27 +- .../chat/items/linkedTool/WriteToolViewer.tsx | 17 +- .../chat/items/linkedTool/renderHelpers.tsx | 191 ++++++++++- .../team/members/MemberExecutionLog.tsx | 3 +- src/renderer/utils/streamJsonParser.ts | 300 ++++++++++++++++-- .../utils/toolRendering/toolContentChecks.ts | 8 +- .../utils/toolRendering/toolSummaryHelpers.ts | 86 +++-- .../BoardTaskLogStreamIntegration.test.ts | 147 +++++++++ .../team/members/MemberExecutionLog.test.ts | 34 ++ .../TaskLogStreamSection.integration.test.ts | 2 +- ...treamSection.opencode-fixture-e2e.test.tsx | 2 +- test/renderer/utils/renderHelpers.test.ts | 48 ++- test/renderer/utils/streamJsonParser.test.ts | 90 ++++++ .../renderer/utils/toolSummaryHelpers.test.ts | 23 ++ 18 files changed, 936 insertions(+), 116 deletions(-) create mode 100644 test/renderer/utils/streamJsonParser.test.ts create mode 100644 test/renderer/utils/toolSummaryHelpers.test.ts diff --git a/runtime.lock.json b/runtime.lock.json index e1bc58d2..41c72111 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.10", - "sourceRef": "v0.0.10", + "version": "0.0.11", + "sourceRef": "v0.0.11", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.10.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.11.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.10.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.11.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.10.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.11.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.10.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.11.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts index f3ff491d..ea78adaa 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -183,7 +183,7 @@ function extractBoardToolOutputText( return null; } - const payload = parsedPayload as Record; + const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record); if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { @@ -194,6 +194,22 @@ function extractBoardToolOutputText( return null; } +function unwrapAgentTeamsResponsePayload( + payload: Record +): Record { + const wrapperKey = Object.keys(payload).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ); + if (!wrapperKey) { + return payload; + } + + const nested = payload[wrapperKey]; + return typeof nested === 'object' && nested !== null && !Array.isArray(nested) + ? (nested as Record) + : payload; +} + function collectTextBlockText(value: unknown): string { if (!Array.isArray(value)) { return ''; @@ -252,9 +268,10 @@ function sanitizeToolResultContent( const parsedPayload = parseJsonLikeString(content.content); const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); if (typeof extractedText === 'string') { + const { is_error: _isError, ...rest } = content; return { - ...content, - content: [{ type: 'text', text: extractedText }], + ...rest, + content: extractedText, }; } return parsedPayload ? { ...content, content: '' } : cloneBlock(content); diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index f4080f60..cf0e8799 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -528,7 +528,7 @@ function extractBoardToolOutputText( } const normalizedToolName = toolName.trim().toLowerCase(); - const payload = parsedPayload as Record; + const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record); if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { @@ -571,6 +571,22 @@ function extractBoardToolOutputText( return null; } +function unwrapAgentTeamsResponsePayload( + payload: Record +): Record { + const wrapperKey = Object.keys(payload).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ); + if (!wrapperKey) { + return payload; + } + + const nested = payload[wrapperKey]; + return typeof nested === 'object' && nested !== null && !Array.isArray(nested) + ? (nested as Record) + : payload; +} + function collectTextBlockText(value: unknown): string { if (!Array.isArray(value)) { return ''; @@ -640,9 +656,10 @@ function sanitizeToolResultContent( const parsedPayload = parseJsonLikeString(content.content); const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); if (typeof extractedText === 'string') { + const { is_error: _isError, ...rest } = content; return { - ...content, - content: [{ type: 'text', text: extractedText }], + ...rest, + content: extractedText, }; } return parsedPayload ? { ...content, content: '' } : cloneBlock(content); @@ -731,6 +748,10 @@ function sanitizeToolResultPayloadValue( return sanitizedChildren.length > 0 ? sanitizedChildren : ''; } +function hasExtractedBoardToolOutput(value: string | unknown[]): boolean { + return typeof value === 'string' && value.trim().length > 0 && !looksLikeJsonPayload(value); +} + function sanitizeJsonLikeToolResultPayloads( messages: ParsedMessage[], canonicalToolName?: string @@ -745,6 +766,7 @@ function sanitizeJsonLikeToolResultPayloads( return { ...toolResult, content: nextContent, + isError: hasExtractedBoardToolOutput(nextContent) ? false : toolResult.isError, }; } return toolResult; diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index e9707e40..336ce076 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -80,6 +80,7 @@ export const LinkedToolItem: React.FC = ({ const status = getToolStatus(linkedTool); const { isLight } = useTheme(); const summary = getToolSummary(linkedTool.name, linkedTool.input); + const normalizedToolName = linkedTool.name.toLowerCase(); const summaryNode = searchQueryOverride && searchQueryOverride.trim().length > 0 ? highlightQueryInText( @@ -159,16 +160,16 @@ export const LinkedToolItem: React.FC = ({ // Determine which specialized viewer to use const useReadViewer = - linkedTool.name === 'Read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; - const useEditViewer = linkedTool.name === 'Edit' && hasEditContent(linkedTool); + normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; + const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool); const useWriteViewer = - linkedTool.name === 'Write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; + normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; // Check if we should show error display for Read/Write tools - const showReadError = linkedTool.name === 'Read' && linkedTool.result?.isError; - const showWriteError = linkedTool.name === 'Write' && linkedTool.result?.isError; + const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError; + const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError; return (
diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 38f8ec70..fc9ae282 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -9,7 +9,12 @@ import React from 'react'; import { type ItemStatus } from '../BaseItem'; import { CollapsibleOutputSection } from './CollapsibleOutputSection'; -import { extractOutputText, renderInput, renderOutput } from './renderHelpers'; +import { + extractOutputText, + formatToolOutputForDisplay, + renderInput, + renderOutput, +} from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -19,10 +24,13 @@ interface DefaultToolViewerProps { } export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { + const displayOutputContent = linkedTool.result + ? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content) + : null; const hasMeaningfulOutput = - linkedTool.result && + displayOutputContent !== null && (() => { - const text = extractOutputText(linkedTool.result.content).trim(); + const text = extractOutputText(displayOutputContent).trim(); return text.length > 0 && text !== '[]' && text !== '{}'; })(); @@ -46,11 +54,14 @@ export const DefaultToolViewer: React.FC = ({ linkedTool
{/* Output Section — Collapsed by default */} - {!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && ( - - {renderOutput(linkedTool.result.content)} - - )} + {!linkedTool.isOrphaned && + linkedTool.result && + hasMeaningfulOutput && + displayOutputContent && ( + + {renderOutput(displayOutputContent)} + + )} ); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index 14fba8aa..c2931cff 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -17,11 +17,22 @@ interface WriteToolViewerProps { export const WriteToolViewer: React.FC = ({ linkedTool }) => { const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; - const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); - const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; + const filePath = + (toolUseResult?.filePath as string) || + (linkedTool.input.file_path as string) || + (linkedTool.input.filePath as string) || + (linkedTool.input.path as string) || + 'write-output'; + const content = + (toolUseResult?.content as string) || + (linkedTool.input.content as string) || + (linkedTool.input.text as string) || + ''; const isCreate = toolUseResult?.type === 'create'; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>( + isMarkdownFile ? 'preview' : 'code' + ); return (
diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 13320b3f..924e5725 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -19,11 +19,12 @@ import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary'; * Renders the input section based on tool type with theme-aware styling. */ export function renderInput(toolName: string, input: Record): React.ReactElement { + const normalizedToolName = toolName.toLowerCase(); // Special rendering for Edit tool - show diff-like format - if (toolName === 'Edit') { - const filePath = input.file_path as string | undefined; - const oldString = input.old_string as string | undefined; - const newString = input.new_string as string | undefined; + if (normalizedToolName === 'edit') { + const filePath = readInputString(input, ['file_path', 'filePath', 'path']); + const oldString = readInputString(input, ['old_string', 'oldString']); + const newString = readInputString(input, ['new_string', 'newString']); const replaceAll = input.replace_all as boolean | undefined; return ( @@ -57,9 +58,9 @@ export function renderInput(toolName: string, input: Record): R } // Special rendering for Bash tool - if (toolName === 'Bash') { - const command = input.command as string | undefined; - const description = input.description as string | undefined; + if (normalizedToolName === 'bash') { + const command = readInputString(input, ['command']); + const description = readInputString(input, ['description']); const highlighted = command ? highlightLines(command, 'command.sh') : null; return ( @@ -81,8 +82,8 @@ export function renderInput(toolName: string, input: Record): R } // Special rendering for Read tool - if (toolName === 'Read') { - const filePath = input.file_path as string | undefined; + if (normalizedToolName === 'read') { + const filePath = readInputString(input, ['file_path', 'filePath', 'path']); const offset = input.offset as number | undefined; const limit = input.limit as number | undefined; @@ -168,18 +169,34 @@ export function renderInput(toolName: string, input: Record): R // Default: key-value format with readable string values return (
- {Object.entries(input).map(([key, value]) => ( -
-
- {key} + {Object.entries(input).length > 0 ? ( + Object.entries(input).map(([key, value]) => ( +
+
+ {key} +
+
{formatInputValue(value)}
-
{formatInputValue(value)}
+ )) + ) : ( +
+ No input recorded for this tool call.
- ))} + )}
); } +function readInputString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return undefined; +} + function formatInputValue(value: unknown): string { if (typeof value === 'string') { return value; @@ -240,6 +257,150 @@ export function extractOutputText(content: string | unknown[]): string { return displayText; } +export function formatToolOutputForDisplay( + toolName: string, + content: string | unknown[] +): string | unknown[] { + if (!isAgentTeamsToolName(toolName)) { + return content; + } + + const parsed = parseJsonObject(extractOutputText(content)); + if (!parsed) { + return content; + } + + const unwrapped = unwrapAgentTeamsResponse(parsed); + if (!unwrapped) { + return content; + } + + const lines = formatAgentTeamsResponse(toolName, unwrapped.wrapperKey, unwrapped.payload); + return lines.length > 0 ? lines.join('\n') : content; +} + +function isAgentTeamsToolName(toolName: string): boolean { + return ( + toolName.startsWith('agent-teams_') || + toolName.startsWith('agent_teams_') || + toolName.startsWith('mcp__agent-teams__') || + toolName.startsWith('mcp__agent_teams__') + ); +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed: unknown = JSON.parse(text); + return asRecord(parsed); + } catch { + return null; + } +} + +function unwrapAgentTeamsResponse( + parsed: Record +): { wrapperKey: string | null; payload: Record } | null { + const wrapperKey = + Object.keys(parsed).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ) ?? null; + const payload = wrapperKey ? asRecord(parsed[wrapperKey]) : parsed; + return payload ? { wrapperKey, payload } : null; +} + +function formatAgentTeamsResponse( + toolName: string, + wrapperKey: string | null, + payload: Record +): string[] { + if (hasErrorPayload(payload)) { + return []; + } + + const lines: string[] = [getAgentTeamsResponseTitle(toolName, wrapperKey)]; + appendField(lines, 'Team', readString(payload.teamName)); + appendField(lines, 'Task ID', readString(payload.taskId)); + appendField(lines, 'Message ID', readString(payload.messageId)); + + const comment = asRecord(payload.comment); + if (comment) { + appendField(lines, 'Comment ID', readString(comment.id)); + appendField(lines, 'Author', readString(comment.author)); + appendField(lines, 'Created', readString(comment.createdAt)); + appendBody(lines, readString(comment.text)); + return lines; + } + + const message = asRecord(payload.message); + if (message) { + appendField(lines, 'From', readString(message.from)); + appendField(lines, 'To', readString(message.to)); + appendField(lines, 'Created', readString(message.createdAt)); + appendBody(lines, readString(message.text) ?? readString(message.summary)); + return lines; + } + + const task = asRecord(payload.task); + if (task) { + appendField(lines, 'Task', readString(task.title) ?? readString(task.name)); + appendField(lines, 'Status', readString(task.status)); + appendField(lines, 'Owner', readString(task.owner)); + appendBody(lines, readString(task.description)); + return lines; + } + + appendField(lines, 'Status', readString(payload.status)); + appendBody(lines, readString(payload.text) ?? readString(payload.summary)); + return lines.length > 1 ? lines : []; +} + +function hasErrorPayload(payload: Record): boolean { + return ( + typeof payload.error === 'string' || + typeof payload.errorMessage === 'string' || + payload.ok === false || + payload.success === false + ); +} + +function getAgentTeamsResponseTitle(toolName: string, wrapperKey: string | null): string { + const key = `${toolName} ${wrapperKey ?? ''}`; + if (key.includes('task_add_comment')) return 'Task comment added'; + if (key.includes('task_complete')) return 'Task completed'; + if (key.includes('task_start')) return 'Task started'; + if (key.includes('task_set_owner')) return 'Task owner updated'; + if (key.includes('task_set_clarification')) return 'Task clarification updated'; + if (key.includes('task_attach_comment_file')) return 'Task comment file attached'; + if (key.includes('message_send')) return 'Message sent'; + if (key.includes('task_get')) return 'Task loaded'; + return 'Agent Teams tool result'; +} + +function appendField(lines: string[], label: string, value: string | null | undefined): void { + if (!value || value.trim().length === 0) { + return; + } + lines.push(`${label}: ${value.trim()}`); +} + +function appendBody(lines: string[], value: string | null | undefined): void { + if (!value || value.trim().length === 0) { + return; + } + lines.push(''); + lines.push(value.trim()); +} + +function readString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + function isContentBlock(value: unknown): boolean { return ( typeof value === 'object' && diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index d39cbd65..e498fbae 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -175,6 +175,7 @@ const AIExecutionGroup = ({ ); return enhanceAIGroup({ ...group, processes: filteredProcesses }); }, [group, memberName]); + const groupLabel = memberName?.trim() ? `${memberName.trim()} turn` : 'Agent turn'; const hasToggleContent = enhanced.displayItems.length > 0; const visibleLastOutput = enhanced.lastOutput?.type === 'tool_result' && hasToggleContent ? null : enhanced.lastOutput; @@ -192,7 +193,7 @@ const AIExecutionGroup = ({ > - Agent + {groupLabel} {enhanced.itemsSummary} diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 54b3645a..5903d478 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -51,6 +51,36 @@ interface ContentBlock { input?: Record; } +type CodexNativeJsonEvent = { + type?: string; + thread_id?: string; + item?: { + id?: string; + type?: string; + text?: string; + server?: string; + tool?: string; + arguments?: unknown; + result?: unknown; + error?: unknown; + status?: string; + }; + usage?: { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + }; +}; + +type CodexNativeProjectedSystemEvent = { + type?: string; + subtype?: string; + content?: string; + level?: string; + codexNativeThreadStatus?: string; + codexNativeThreadId?: string; +}; + /** * Content-based hash for deterministic fallback IDs that survive * line reordering and pagination changes. @@ -95,6 +125,170 @@ function extractContentBlocks(parsed: unknown): ContentBlock[] | null { return null; } +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function extractCodexToolResultText(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + + const record = asRecord(value); + const content = record?.content; + if (Array.isArray(content)) { + const text = content + .map((block) => asRecord(block)) + .map((block) => (block?.type === 'text' && typeof block.text === 'string' ? block.text : '')) + .filter((entry) => entry.trim().length > 0) + .join('\n') + .trim(); + if (text) return text; + } + + if (record?.structured_content != null) { + return JSON.stringify(record.structured_content); + } + + return null; +} + +function extractCodexToolErrorText(value: unknown): string | null { + const record = asRecord(value); + const message = record?.message; + return typeof message === 'string' && message.trim() ? message.trim() : null; +} + +function getCodexToolDisplayName(serverName: string, toolName: string): string { + return serverName === 'agent-teams' ? `agent-teams_${toolName}` : `${serverName}_${toolName}`; +} + +function createCodexToolItem( + event: CodexNativeJsonEvent, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem | null { + const item = event.item; + if ( + (event.type !== 'item.started' && event.type !== 'item.completed') || + item?.type !== 'mcp_tool_call' || + typeof item.server !== 'string' || + typeof item.tool !== 'string' + ) { + return null; + } + + const input = asRecord(item.arguments) ?? {}; + const status = typeof item.status === 'string' && item.status.trim() ? item.status : 'unknown'; + const errorText = extractCodexToolErrorText(item.error); + const resultText = extractCodexToolResultText(item.result); + const isCompleted = event.type === 'item.completed'; + const toolName = getCodexToolDisplayName(item.server, item.tool); + const linkedTool: LinkedToolItem = { + id: item.id ?? `codex-tool-L${lineIndex}`, + name: toolName, + input, + inputPreview: getToolSummary(toolName, input), + startTime: timestamp, + isOrphaned: !isCompleted, + }; + + if (isCompleted) { + linkedTool.endTime = timestamp; + linkedTool.isOrphaned = false; + if (resultText || errorText) { + linkedTool.result = { + content: resultText ?? errorText ?? '', + isError: status === 'failed' || errorText !== null, + }; + linkedTool.outputPreview = resultText ?? errorText ?? undefined; + } + } + + return { type: 'tool', tool: linkedTool }; +} + +function codexNativeEventToDisplayItems( + parsed: unknown, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem[] | null { + const event = asRecord(parsed) as CodexNativeJsonEvent | null; + if (!event || typeof event.type !== 'string') { + return null; + } + + if (event.type === 'thread.started') { + const threadId = + typeof event.thread_id === 'string' && event.thread_id.trim() + ? `: ${event.thread_id.trim()}` + : ''; + return [{ type: 'output', content: `Codex native thread started${threadId}.`, timestamp }]; + } + + if (event.type === 'turn.started') { + return [{ type: 'output', content: 'Codex turn started.', timestamp }]; + } + + if (event.type === 'turn.completed') { + const usage = event.usage; + const usageParts = [ + typeof usage?.input_tokens === 'number' ? `${usage.input_tokens} input` : null, + typeof usage?.cached_input_tokens === 'number' ? `${usage.cached_input_tokens} cached` : null, + typeof usage?.output_tokens === 'number' ? `${usage.output_tokens} output` : null, + ].filter((part): part is string => Boolean(part)); + const suffix = usageParts.length > 0 ? ` (${usageParts.join(', ')} tokens)` : ''; + return [{ type: 'output', content: `Codex turn completed${suffix}.`, timestamp }]; + } + + if ( + event.type === 'item.completed' && + event.item?.type === 'agent_message' && + typeof event.item.text === 'string' && + event.item.text.trim() + ) { + return [{ type: 'output', content: event.item.text.trim(), timestamp }]; + } + + const toolItem = createCodexToolItem(event, timestamp, lineIndex); + if (toolItem) { + return [toolItem]; + } + + return null; +} + +function codexNativeProjectedSystemToDisplayItems( + parsed: unknown, + timestamp: Date +): AIGroupDisplayItem[] | null { + const event = asRecord(parsed) as CodexNativeProjectedSystemEvent | null; + if (!event || event.type !== 'system' || typeof event.subtype !== 'string') { + return null; + } + + if ( + event.subtype !== 'codex_native_thread_status' && + event.subtype !== 'codex_native_warning' && + event.subtype !== 'codex_native_execution_summary' + ) { + return null; + } + + const content = + typeof event.content === 'string' && event.content.trim() + ? event.content.trim() + : event.subtype === 'codex_native_thread_status' + ? `Codex native thread ${event.codexNativeThreadStatus ?? 'status'}${ + event.codexNativeThreadId ? `: ${event.codexNativeThreadId}` : '' + }.` + : null; + + return content ? [{ type: 'output', content, timestamp }] : null; +} + /** * Converts content blocks from a single assistant message into display items. * @param lineIndex - stable line position for deterministic fallback IDs @@ -220,6 +414,64 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] const msgIdOccurrences = new Map(); const hashOccurrences = new Map(); + const ensureCurrentTimestamp = (line: string): Date => { + if (currentTimestamp) return currentTimestamp; + // Use stable cached timestamp keyed by line content to survive re-parses + let ts = lineTimestampCache.get(line); + if (!ts) { + ts = new Date(); + if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) { + // Evict oldest entry (first inserted) + const firstKey = lineTimestampCache.keys().next().value!; + lineTimestampCache.delete(firstKey); + } + lineTimestampCache.set(line, ts); + } + currentTimestamp = ts; + return ts; + }; + + const ensureCurrentGroupId = ( + line: string, + parsed: unknown, + lineAgentId: string | undefined + ): void => { + if (currentGroupId) return; + currentAgentId = lineAgentId; + const msgId = extractAssistantMessageId(parsed); + if (msgId) { + const occurrence = msgIdOccurrences.get(msgId) ?? 0; + msgIdOccurrences.set(msgId, occurrence + 1); + currentGroupId = + occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`; + return; + } + + // Content-hash fallback: deterministic and survives line reordering + const h = stableHash(line); + const occ = hashOccurrences.get(h) ?? 0; + hashOccurrences.set(h, occ + 1); + currentGroupId = occ === 0 ? `stream-group-H${h}` : `stream-group-H${h}-${occ}`; + }; + + const pushItems = (items: AIGroupDisplayItem[]): void => { + for (const item of items) { + if (item.type !== 'tool') { + currentItems.push(item); + continue; + } + + const existingIndex = currentItems.findIndex( + (entry) => entry.type === 'tool' && entry.tool.id === item.tool.id + ); + if (existingIndex === -1) { + currentItems.push(item); + } else { + currentItems[existingIndex] = item; + } + } + }; + const flushGroup = (): void => { if (currentItems.length > 0 && currentTimestamp) { const id = currentGroupId ?? `stream-group-fallback-${groups.length}`; @@ -268,7 +520,17 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] const blocks = extractContentBlocks(parsed); if (!blocks) { - // Valid JSON but not an assistant message — flush and skip + const timestamp = ensureCurrentTimestamp(trimmed); + const codexItems = + codexNativeEventToDisplayItems(parsed, timestamp, lineIndex) ?? + codexNativeProjectedSystemToDisplayItems(parsed, timestamp); + if (codexItems && codexItems.length > 0) { + ensureCurrentGroupId(trimmed, parsed, undefined); + pushItems(codexItems); + continue; + } + + // Valid JSON but not a displayable log event — flush and skip flushGroup(); continue; } @@ -279,39 +541,11 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] ? ((parsed as Record).agentId as string) : undefined; - if (!currentTimestamp) { - // Use stable cached timestamp keyed by line content to survive re-parses - let ts = lineTimestampCache.get(trimmed); - if (!ts) { - ts = new Date(); - if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) { - // Evict oldest entry (first inserted) - const firstKey = lineTimestampCache.keys().next().value!; - lineTimestampCache.delete(firstKey); - } - lineTimestampCache.set(trimmed, ts); - } - currentTimestamp = ts; - } - if (!currentGroupId) { - currentAgentId = lineAgentId; - const msgId = extractAssistantMessageId(parsed); - if (msgId) { - const occurrence = msgIdOccurrences.get(msgId) ?? 0; - msgIdOccurrences.set(msgId, occurrence + 1); - currentGroupId = - occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`; - } else { - // Content-hash fallback: deterministic and survives line reordering - const h = stableHash(trimmed); - const occ = hashOccurrences.get(h) ?? 0; - hashOccurrences.set(h, occ + 1); - currentGroupId = occ === 0 ? `stream-group-H${h}` : `stream-group-H${h}-${occ}`; - } - } + const timestamp = ensureCurrentTimestamp(trimmed); + ensureCurrentGroupId(trimmed, parsed, lineAgentId); - const items = contentBlocksToDisplayItems(blocks, currentTimestamp, lineIndex); - currentItems.push(...items); + const items = contentBlocksToDisplayItems(blocks, timestamp, lineIndex); + pushItems(items); } // Flush remaining items diff --git a/src/renderer/utils/toolRendering/toolContentChecks.ts b/src/renderer/utils/toolRendering/toolContentChecks.ts index 090cf546..4dd5050d 100644 --- a/src/renderer/utils/toolRendering/toolContentChecks.ts +++ b/src/renderer/utils/toolRendering/toolContentChecks.ts @@ -49,7 +49,13 @@ export function hasEditContent(linkedTool: LinkedToolItem): boolean { * Checks if a Write tool has content to display. */ export function hasWriteContent(linkedTool: LinkedToolItem): boolean { - if (linkedTool.input.content != null || linkedTool.input.file_path != null) return true; + if ( + linkedTool.input.content != null || + linkedTool.input.file_path != null || + linkedTool.input.filePath != null || + linkedTool.input.path != null + ) + return true; const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; if (toolUseResult?.content != null || toolUseResult?.filePath != null) return true; diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts index fe9aa778..44badd66 100644 --- a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -15,15 +15,27 @@ function truncate(str: string, maxLength: number): string { return str.slice(0, maxLength) + '...'; } +function readString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return undefined; +} + /** * Generates a human-readable summary for a tool call. */ export function getToolSummary(toolName: string, input: Record): string { - switch (toolName) { - case 'Edit': { - const filePath = input.file_path as string | undefined; - const oldString = input.old_string as string | undefined; - const newString = input.new_string as string | undefined; + const normalizedToolName = toolName.toLowerCase(); + + switch (normalizedToolName) { + case 'edit': { + const filePath = readString(input, ['file_path', 'filePath', 'path']); + const oldString = readString(input, ['old_string', 'oldString']); + const newString = readString(input, ['new_string', 'newString']); if (!filePath) return 'Edit'; @@ -42,8 +54,8 @@ export function getToolSummary(toolName: string, input: Record) return fileName; } - case 'Read': { - const filePath = input.file_path as string | undefined; + case 'read': { + const filePath = readString(input, ['file_path', 'filePath', 'path']); const limit = input.limit as number | undefined; const offset = input.offset as number | undefined; @@ -59,11 +71,15 @@ export function getToolSummary(toolName: string, input: Record) return fileName; } - case 'Write': { - const filePath = input.file_path as string | undefined; - const content = input.content as string | undefined; + case 'write': { + const filePath = readString(input, ['file_path', 'filePath', 'path']); + const content = readString(input, ['content', 'text']); - if (!filePath) return 'Write'; + if (!filePath) { + return content + ? `content - ${content.split('\n').length} lines` + : 'Write input unavailable'; + } const fileName = getBaseName(filePath); @@ -75,9 +91,9 @@ export function getToolSummary(toolName: string, input: Record) return fileName; } - case 'Bash': { - const command = input.command as string | undefined; - const description = input.description as string | undefined; + case 'bash': { + const command = readString(input, ['command']); + const description = readString(input, ['description']); // Prefer description if available if (description) { @@ -91,10 +107,10 @@ export function getToolSummary(toolName: string, input: Record) return 'Bash'; } - case 'Grep': { - const pattern = input.pattern as string | undefined; - const path = input.path as string | undefined; - const glob = input.glob as string | undefined; + case 'grep': { + const pattern = readString(input, ['pattern']); + const path = readString(input, ['path']); + const glob = readString(input, ['glob']); if (!pattern) return 'Grep'; @@ -110,9 +126,9 @@ export function getToolSummary(toolName: string, input: Record) return patternStr; } - case 'Glob': { - const pattern = input.pattern as string | undefined; - const path = input.path as string | undefined; + case 'glob': { + const pattern = readString(input, ['pattern']); + const path = readString(input, ['path']); if (!pattern) return 'Glob'; @@ -125,7 +141,7 @@ export function getToolSummary(toolName: string, input: Record) return patternStr; } - case 'Task': { + case 'task': { const prompt = input.prompt as string | undefined; const subagentType = input.subagentType as string | undefined; const description = input.description as string | undefined; @@ -140,7 +156,7 @@ export function getToolSummary(toolName: string, input: Record) return subagentType ?? 'Task'; } - case 'LSP': { + case 'lsp': { const operation = input.operation as string | undefined; const filePath = input.filePath as string | undefined; @@ -153,7 +169,7 @@ export function getToolSummary(toolName: string, input: Record) return operation; } - case 'WebFetch': { + case 'webfetch': { const url = input.url as string | undefined; if (url) { @@ -168,7 +184,7 @@ export function getToolSummary(toolName: string, input: Record) return 'WebFetch'; } - case 'WebSearch': { + case 'websearch': { const query = input.query as string | undefined; if (query) { @@ -178,7 +194,7 @@ export function getToolSummary(toolName: string, input: Record) return 'WebSearch'; } - case 'TodoWrite': { + case 'todowrite': { const todos = input.todos as unknown[] | undefined; if (todos && Array.isArray(todos)) { @@ -188,7 +204,7 @@ export function getToolSummary(toolName: string, input: Record) return 'TodoWrite'; } - case 'NotebookEdit': { + case 'notebookedit': { const notebookPath = input.notebook_path as string | undefined; const editMode = input.edit_mode as string | undefined; @@ -204,19 +220,19 @@ export function getToolSummary(toolName: string, input: Record) // Team Tools // ========================================================================= - case 'TeamCreate': { + case 'teamcreate': { const teamName = input.team_name as string | undefined; const desc = input.description as string | undefined; if (teamName) return `${teamName}${desc ? ' - ' + truncate(desc, 30) : ''}`; return 'Create team'; } - case 'TaskCreate': { + case 'taskcreate': { const subject = input.subject as string | undefined; return subject ? truncate(subject, 50) : 'Create task'; } - case 'TaskUpdate': { + case 'taskupdate': { const taskId = input.taskId as string | undefined; const status = input.status as string | undefined; const owner = input.owner as string | undefined; @@ -227,15 +243,15 @@ export function getToolSummary(toolName: string, input: Record) return parts.length > 0 ? parts.join(' ') : 'Update task'; } - case 'TaskList': + case 'tasklist': return 'List tasks'; - case 'TaskGet': { + case 'taskget': { const taskId = input.taskId as string | undefined; return taskId ? `Get task #${taskId}` : 'Get task'; } - case 'SendMessage': { + case 'sendmessage': { const msgType = input.type as string | undefined; const recipient = input.recipient as string | undefined; const summary = input.summary as string | undefined; @@ -246,10 +262,10 @@ export function getToolSummary(toolName: string, input: Record) return 'Send message'; } - case 'TeamDelete': + case 'teamdelete': return 'Delete team'; - case 'Agent': { + case 'agent': { return summarizeAgentToolInput(input, 60); } diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index 9d1f2790..6b125d42 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -947,6 +947,153 @@ describe('BoardTaskLogStreamService integration', () => { }); }); + it('unwraps Agent Teams response envelopes before rendering task comment output', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-agent-teams-envelope-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'bob', + workIntervals: [ + { + startedAt: '2026-04-27T20:05:00.000Z', + completedAt: '2026-04-27T20:10:00.000Z', + }, + ], + }); + const payload = { + agent_teams_task_add_comment_response: { + comment: { + attachments: [], + author: 'bob', + createdAt: '2026-04-27T20:05:44.248Z', + id: '40203f1f-44e2-45e0-b6a8-2b812fb7ac12', + text: 'Создана папка `944` и файл `calculator.js`.', + }, + taskId: TASK_ID, + teamName: TEAM_NAME, + }, + }; + + const lines = [ + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-27T20:05:44.000Z', + requestId: 'req-comment', + agentName: 'bob', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'agent-teams_task_add_comment', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + from: 'bob', + text: 'Создана папка `944` и файл `calculator.js`.', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-27T20:05:44.248Z', + sourceToolAssistantUUID: 'a-comment', + agentName: 'bob', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: JSON.stringify(payload), + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'owner', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + }, + ], + toolUseResult: { + toolUseId: 'call-comment', + content: JSON.stringify(payload), + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'bob', agentType: 'developer' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const commentResult = rawMessages.find((message) => message.uuid === 'u-comment'); + const semanticToolResult = response.segments + .flatMap((segment) => segment.chunks) + .flatMap((chunk) => ('semanticSteps' in chunk ? (chunk.semanticSteps ?? []) : [])) + .find((step) => step.type === 'tool_result' && step.id === 'call-comment'); + + expect(commentResult?.toolResults).toEqual([ + { + toolUseId: 'call-comment', + content: 'Создана папка `944` и файл `calculator.js`.', + isError: false, + }, + ]); + expect(semanticToolResult).toMatchObject({ + id: 'call-comment', + type: 'tool_result', + content: expect.objectContaining({ + toolResultContent: 'Создана папка `944` и файл `calculator.js`.', + }), + }); + expect(JSON.stringify(response)).not.toContain('agent_teams_task_add_comment_response'); + }); + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); tempDirs.push(dir); diff --git a/test/renderer/components/team/members/MemberExecutionLog.test.ts b/test/renderer/components/team/members/MemberExecutionLog.test.ts index a924be4a..6e347df4 100644 --- a/test/renderer/components/team/members/MemberExecutionLog.test.ts +++ b/test/renderer/components/team/members/MemberExecutionLog.test.ts @@ -183,4 +183,38 @@ describe('MemberExecutionLog', () => { await flushMicrotasks(); }); }); + + it('uses the member name for execution group labels when provided', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setSingleAiGroup(); + enhanceState.value = { + displayItems: [ + { + type: 'tool', + id: 'tool-1', + toolName: 'read', + timestamp: new Date('2026-04-18T13:23:11.000Z'), + }, + ], + itemsSummary: '1 tool call', + lastOutput: null, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(MemberExecutionLog, { chunks: [], memberName: 'jack' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('jack turn'); + expect(host.textContent).not.toContain('Agent turn'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); }); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index 3fb6efd3..e3155d10 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -414,7 +414,7 @@ describe('TaskLogStreamSection integration', () => { expect(text).toContain('Task Log Stream'); expect(text).toContain('Grep'); expect(text).toContain('Edit'); - expect(text).toContain('Agent'); + expect(text).toContain('tom turn'); expect(text).toContain('3 tool calls'); expect(text).not.toContain('[]'); expect(text).not.toContain('Audit complete'); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx index 8351f672..ed594cf4 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx @@ -144,7 +144,7 @@ describe('TaskLogStreamSection OpenCode real fixture e2e', () => { const text = host.textContent ?? ''; expect(text).toContain('Task Log Stream'); expect(text).toContain('matched task tool markers'); - expect(text).toContain('Agent'); + expect(text).toContain('jack turn'); expect(text).toContain('Calculator behavior'); expect(text).toContain('Задача #0b3a0624 завершена'); expect(text).not.toContain('Keyboard handlers added'); diff --git a/test/renderer/utils/renderHelpers.test.ts b/test/renderer/utils/renderHelpers.test.ts index f1aaa35d..89be2fc6 100644 --- a/test/renderer/utils/renderHelpers.test.ts +++ b/test/renderer/utils/renderHelpers.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers'; +import { + extractOutputText, + formatToolOutputForDisplay, +} from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers'; describe('renderHelpers', () => { describe('extractOutputText', () => { @@ -56,4 +59,47 @@ describe('renderHelpers', () => { expect(result).toContain('"type": "image"'); }); }); + + describe('formatToolOutputForDisplay', () => { + it('unwraps Agent Teams task comment responses for display', () => { + const raw = JSON.stringify({ + agent_teams_task_add_comment_response: { + comment: { + attachments: [], + author: 'bob', + createdAt: '2026-04-27T20:05:44.248Z', + id: '40203f1f-44e2-45e0-b6a8-2b812fb7ac12', + text: 'Создана папка `944` и файл `calculator.js`.', + }, + taskId: '03561cb3-55d3-46c1-9f06-b928750936a9', + teamName: 'forge-labs-9', + }, + }); + + const result = formatToolOutputForDisplay('agent-teams_task_add_comment', raw); + + expect(result).toContain('Task comment added'); + expect(result).toContain('Team: forge-labs-9'); + expect(result).toContain('Comment ID: 40203f1f-44e2-45e0-b6a8-2b812fb7ac12'); + expect(result).toContain('Создана папка `944`'); + expect(result).not.toContain('agent_teams_task_add_comment_response'); + }); + + it('does not rewrite non Agent Teams JSON output', () => { + const raw = JSON.stringify({ agent_teams_task_add_comment_response: { ok: true } }); + + expect(formatToolOutputForDisplay('bash', raw)).toBe(raw); + }); + + it('keeps Agent Teams error payloads raw for debugging', () => { + const raw = JSON.stringify({ + agent_teams_task_add_comment_response: { + error: 'Task not found', + taskId: 'missing', + }, + }); + + expect(formatToolOutputForDisplay('agent-teams_task_add_comment', raw)).toBe(raw); + }); + }); }); diff --git a/test/renderer/utils/streamJsonParser.test.ts b/test/renderer/utils/streamJsonParser.test.ts new file mode 100644 index 00000000..6a5140a0 --- /dev/null +++ b/test/renderer/utils/streamJsonParser.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; + +describe('parseStreamJsonToGroups', () => { + it('renders Codex native JSONL lifecycle and assistant text instead of showing an empty viewer', () => { + const groups = parseStreamJsonToGroups( + [ + '[stdout]', + '{"type":"thread.started","thread_id":"thread-1"}', + '{"type":"turn.started"}', + '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Lead response ready."}}', + '{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":25,"output_tokens":7}}', + ].join('\n') + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'output', content: 'Codex native thread started: thread-1.' }), + expect.objectContaining({ type: 'output', content: 'Codex turn started.' }), + expect.objectContaining({ type: 'output', content: 'Lead response ready.' }), + expect.objectContaining({ + type: 'output', + content: 'Codex turn completed (100 input, 25 cached, 7 output tokens).', + }), + ]) + ); + }); + + it('deduplicates Codex native MCP tool started/completed events by item id', () => { + const groups = parseStreamJsonToGroups( + [ + '{"type":"item.started","item":{"id":"item_1","type":"mcp_tool_call","server":"agent-teams","tool":"message_send","arguments":{"teamName":"signal-ops-11"},"status":"in_progress"}}', + '{"type":"item.completed","item":{"id":"item_1","type":"mcp_tool_call","server":"agent-teams","tool":"message_send","arguments":{"teamName":"signal-ops-11"},"result":{"content":[{"type":"text","text":"sent"}]},"status":"completed"}}', + ].join('\n') + ); + + const tools = groups.flatMap((group) => group.items).filter((item) => item.type === 'tool'); + + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ + type: 'tool', + tool: { + id: 'item_1', + name: 'agent-teams_message_send', + isOrphaned: false, + result: { + content: 'sent', + isError: false, + }, + }, + }); + }); + + it('renders projected Codex native system status rows from persisted logs', () => { + const groups = parseStreamJsonToGroups( + [ + '{"type":"system","subtype":"codex_native_thread_status","content":"Codex native thread started (thread-1).","codexNativeThreadStatus":"running","codexNativeThreadId":"thread-1"}', + '{"type":"system","subtype":"codex_native_execution_summary","content":"Codex native execution summary: ephemeral live-only."}', + ].join('\n') + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'output', + content: 'Codex native thread started (thread-1).', + }), + expect.objectContaining({ + type: 'output', + content: 'Codex native execution summary: ephemeral live-only.', + }), + ]) + ); + }); + + it('keeps legacy assistant stream-json behavior', () => { + const groups = parseStreamJsonToGroups( + '{"type":"assistant","message":{"id":"msg_1","content":[{"type":"text","text":"Legacy assistant output."}]}}' + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe('stream-group-msg_1'); + expect(groups[0]?.items).toEqual([ + expect.objectContaining({ type: 'output', content: 'Legacy assistant output.' }), + ]); + }); +}); diff --git a/test/renderer/utils/toolSummaryHelpers.test.ts b/test/renderer/utils/toolSummaryHelpers.test.ts new file mode 100644 index 00000000..dcddf378 --- /dev/null +++ b/test/renderer/utils/toolSummaryHelpers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { getToolSummary } from '../../../src/renderer/utils/toolRendering/toolSummaryHelpers'; + +describe('renderer toolSummaryHelpers', () => { + it('summarizes OpenCode lowercase write calls with camelCase filePath', () => { + expect( + getToolSummary('write', { + filePath: '/repo/944/index.html', + content: '\n', + }) + ).toBe('index.html - 2 lines'); + }); + + it('shows an explicit unavailable summary for invalid empty write calls', () => { + expect(getToolSummary('write', {})).toBe('Write input unavailable'); + }); + + it('summarizes OpenCode lowercase read and bash calls', () => { + expect(getToolSummary('read', { filePath: '/repo/944/style.css' })).toBe('style.css'); + expect(getToolSummary('bash', { command: 'mkdir -p 944' })).toBe('mkdir -p 944'); + }); +});