fix(team): improve opencode task log rendering

This commit is contained in:
777genius 2026-04-27 23:50:23 +03:00
parent 531a10e34f
commit 8f7712ed74
18 changed files with 936 additions and 116 deletions

View file

@ -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"
}

View file

@ -183,7 +183,7 @@ function extractBoardToolOutputText(
return null;
}
const payload = parsedPayload as Record<string, unknown>;
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
const comment = payload.comment as Record<string, unknown> | undefined;
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
@ -194,6 +194,22 @@ function extractBoardToolOutputText(
return null;
}
function unwrapAgentTeamsResponsePayload(
payload: Record<string, unknown>
): Record<string, unknown> {
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<string, unknown>)
: 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);

View file

@ -528,7 +528,7 @@ function extractBoardToolOutputText(
}
const normalizedToolName = toolName.trim().toLowerCase();
const payload = parsedPayload as Record<string, unknown>;
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
const comment = payload.comment as Record<string, unknown> | undefined;
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
@ -571,6 +571,22 @@ function extractBoardToolOutputText(
return null;
}
function unwrapAgentTeamsResponsePayload(
payload: Record<string, unknown>
): Record<string, unknown> {
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<string, unknown>)
: 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;

View file

@ -80,6 +80,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
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<LinkedToolItemProps> = ({
// 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 (
<div ref={handleRef}>

View file

@ -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<DefaultToolViewerProps> = ({ 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<DefaultToolViewerProps> = ({ linkedTool
</div>
{/* Output Section — Collapsed by default */}
{!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && (
<CollapsibleOutputSection status={status}>
{renderOutput(linkedTool.result.content)}
</CollapsibleOutputSection>
)}
{!linkedTool.isOrphaned &&
linkedTool.result &&
hasMeaningfulOutput &&
displayOutputContent && (
<CollapsibleOutputSection status={status}>
{renderOutput(displayOutputContent)}
</CollapsibleOutputSection>
)}
</>
);
};

View file

@ -17,11 +17,22 @@ interface WriteToolViewerProps {
export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool }) => {
const toolUseResult = linkedTool.result?.toolUseResult as Record<string, unknown> | 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 (
<div className="space-y-2">

View file

@ -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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string, unknown>): R
// Default: key-value format with readable string values
return (
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
{Object.entries(input).map(([key, value]) => (
<div key={key}>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
{key}
{Object.entries(input).length > 0 ? (
Object.entries(input).map(([key, value]) => (
<div key={key}>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
{key}
</div>
<pre className="whitespace-pre-wrap break-all">{formatInputValue(value)}</pre>
</div>
<pre className="whitespace-pre-wrap break-all">{formatInputValue(value)}</pre>
))
) : (
<div className="italic" style={{ color: COLOR_TEXT_MUTED }}>
No input recorded for this tool call.
</div>
))}
)}
</div>
);
}
function readInputString(input: Record<string, unknown>, 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<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(text);
return asRecord(parsed);
} catch {
return null;
}
}
function unwrapAgentTeamsResponse(
parsed: Record<string, unknown>
): { wrapperKey: string | null; payload: Record<string, unknown> } | 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, unknown>
): 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<string, unknown>): 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<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isContentBlock(value: unknown): boolean {
return (
typeof value === 'object' &&

View file

@ -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 = ({
>
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
Agent
{groupLabel}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
{enhanced.itemsSummary}

View file

@ -51,6 +51,36 @@ interface ContentBlock {
input?: Record<string, unknown>;
}
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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, number>();
const hashOccurrences = new Map<string, number>();
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<string, unknown>).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

View file

@ -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<string, unknown> | undefined;
if (toolUseResult?.content != null || toolUseResult?.filePath != null) return true;

View file

@ -15,15 +15,27 @@ function truncate(str: string, maxLength: number): string {
return str.slice(0, maxLength) + '...';
}
function readString(input: Record<string, unknown>, 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, unknown>): 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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
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<string, unknown>)
// 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<string, unknown>)
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<string, unknown>)
return 'Send message';
}
case 'TeamDelete':
case 'teamdelete':
return 'Delete team';
case 'Agent': {
case 'agent': {
return summarizeAgentToolInput(input, 60);
}

View file

@ -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);

View file

@ -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();
});
});
});

View file

@ -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');

View file

@ -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');

View file

@ -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);
});
});
});

View file

@ -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.' }),
]);
});
});

View file

@ -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: '<!DOCTYPE html>\n<html></html>',
})
).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');
});
});