fix(team): improve opencode task log rendering
This commit is contained in:
parent
531a10e34f
commit
8f7712ed74
18 changed files with 936 additions and 116 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
90
test/renderer/utils/streamJsonParser.test.ts
Normal file
90
test/renderer/utils/streamJsonParser.test.ts
Normal 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.' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
23
test/renderer/utils/toolSummaryHelpers.test.ts
Normal file
23
test/renderer/utils/toolSummaryHelpers.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue