diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index 5042bce2..fccf53f5 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -28,7 +28,7 @@ import type { const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_HEIGHT = 292; const NEW_LOG_HIGHLIGHT_MS = 1_000; -const COMPACT_ROW_TEXT_LIMIT = 118; +const COMPACT_ROW_TEXT_LIMIT = 92; const COMPACT_ROW_MIN_PREVIEW_LIMIT = 48; interface StableRectLike { diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index a69147d1..10d7b344 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -458,6 +458,52 @@ Reply to this comment using MCP tool task_add_comment. }); }); + it('marks plain failed tool-result text as an error when runtime flags are missing', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'read-task-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-task-get', + name: 'agent-teams_task_get', + input: { + taskId: '211e430b-0901-4c9e-9296-2b6e2059a08f', + }, + }, + ], + }), + message({ + uuid: 'read-task-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-task-get', + content: + "Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f", + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Read task error', + preview: + "Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f", + tone: 'error', + }); + }); + it('formats orphan comment result payloads without guessing add vs read semantics', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', @@ -927,6 +973,292 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items).toHaveLength(2); }); + it('formats runtime housekeeping previews without leaking internal fields', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'briefing-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-briefing', + name: 'agent-teams_member_briefing', + input: { + teamName: 'relay-works-10', + memberName: 'jack', + runtimeProvider: 'opencode', + }, + }, + ], + }), + message({ + uuid: 'briefing-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-briefing', + content: + 'Member briefing for jack on team "relay-works-10" (relay-works-10). Role: developer. CRITICAL: hidden long briefing details.', + }, + ], + }), + message({ + uuid: 'checkin-call', + timestamp: '2026-04-01T10:02:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-checkin', + name: 'agent-teams_runtime_bootstrap_checkin', + input: { + teamName: 'relay-works-10', + runId: 'run-1', + memberName: 'jack', + runtimeSessionId: 'ses-1', + }, + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Runtime check-in', + preview: 'jack checked in', + }); + expect(result.items[1]).toMatchObject({ + kind: 'tool_result', + title: 'Member briefing', + preview: 'Loaded briefing for jack', + }); + expect(JSON.stringify(result.items)).not.toContain('runtimeSessionId'); + expect(JSON.stringify(result.items)).not.toContain('CRITICAL'); + + const inputOnly = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'briefing-input-only', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-briefing-only', + name: 'agent-teams_member_briefing', + input: { + teamName: 'relay-works-10', + memberName: 'jack', + runtimeProvider: 'opencode', + }, + }, + ], + }), + ], + }); + + expect(inputOnly.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Member briefing', + preview: 'Loaded briefing for jack', + }); + + const failedBriefing = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'briefing-call-failed', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-briefing-failed', + name: 'agent-teams_member_briefing', + input: { + teamName: 'relay-works-10', + memberName: 'jack', + }, + }, + ], + }), + message({ + uuid: 'briefing-result-failed', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-briefing-failed', + content: "Tool 'member_briefing' execution failed: runtime session missing", + }, + ], + }), + ], + }); + + expect(failedBriefing.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Member briefing error', + preview: "Tool 'member_briefing' execution failed: runtime session missing", + tone: 'error', + }); + }); + + it('formats runtime ops, work sync and process previews without internal ids', () => { + const runtimeResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'heartbeat-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-heartbeat', + name: 'agent-teams_runtime_heartbeat', + input: { + runId: 'run-1', + teamName: 'relay-works-10', + memberName: 'jack', + runtimeSessionId: 'ses-1', + }, + }, + ], + }), + message({ + uuid: 'runtime-event-call', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-runtime-event', + name: 'agent-teams_runtime_task_event', + input: { + memberName: 'jack', + taskId: 'abc12345-0000-0000-0000-000000000000', + event: 'started', + }, + }, + ], + }), + ], + }); + + expect(runtimeResult.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Runtime task event', + preview: 'jack started #abc12345', + }); + expect(runtimeResult.items[1]).toMatchObject({ + kind: 'tool_use', + title: 'Runtime heartbeat', + preview: 'jack heartbeat', + }); + expect(JSON.stringify(runtimeResult.items)).not.toContain('runtimeSessionId'); + + const workSyncResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'work-sync-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-work-sync', + name: 'agent-teams_member_work_sync_report', + input: { + memberName: 'jack', + state: 'still_working', + taskIds: ['abc12345-0000-0000-0000-000000000000'], + reportToken: 'secret-token', + }, + }, + ], + }), + message({ + uuid: 'work-sync-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-work-sync', + content: 'ok', + }, + ], + }), + ], + }); + + expect(workSyncResult.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Work sync report', + preview: 'jack still_working #abc12345', + }); + expect(JSON.stringify(workSyncResult.items)).not.toContain('reportToken'); + + const processResult = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'process-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-process-list', + name: 'agent-teams_process_list', + input: { teamName: 'relay-works-10' }, + }, + ], + }), + message({ + uuid: 'process-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-process-list', + content: JSON.stringify([ + { pid: 123, label: 'vite dev', status: 'running' }, + { pid: 456, command: 'pnpm test', status: 'exited' }, + ]), + }, + ], + }), + ], + }); + + expect(processResult.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Process list', + preview: '2 processes - vite dev running; pnpm test exited', + }); + expect(processResult.items[0]?.preview).not.toContain('[{'); + }); + it('uses concrete names for generic runtime tool results', () => { const result = extractMemberLogPreviewItems({ provider: 'opencode_runtime', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 84c3dc42..754f43fb 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -469,6 +469,8 @@ function formatToolTitle(toolName: string): string { if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; if (canonical === 'cross_team_send') return 'Cross-team message'; if (canonical === 'runtime_deliver_message') return 'Runtime delivery'; + if (canonical === 'runtime_task_event') return 'Runtime task event'; + if (canonical === 'runtime_heartbeat') return 'Runtime heartbeat'; if (canonical === 'task_create' || canonical === 'task_create_from_message') return 'Create task'; if (canonical === 'task_complete') return 'Complete task'; if (canonical === 'task_add_comment') return 'Add comment'; @@ -491,6 +493,8 @@ function formatToolTitle(toolName: string): string { if (canonical === 'review_request_changes') return 'Request changes'; if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; if (canonical === 'member_briefing') return 'Member briefing'; + if (canonical === 'member_work_sync_status') return 'Work sync status'; + if (canonical === 'member_work_sync_report') return 'Work sync report'; if (canonical === 'task_add') return 'Add task'; if (canonical === 'task_update') return 'Update task'; if (canonical === 'task_delete') return 'Delete task'; @@ -524,7 +528,11 @@ function isToolUseSupersededBySuccessResult(toolName: string): boolean { canonical === 'cross_team_send' || canonical === 'runtime_deliver_message' || canonical === 'runtime_bootstrap_checkin' || + canonical === 'runtime_heartbeat' || + canonical === 'runtime_task_event' || canonical === 'member_briefing' || + canonical === 'member_work_sync_status' || + canonical === 'member_work_sync_report' || canonical.startsWith('task_') || canonical.startsWith('review_') ); @@ -686,6 +694,77 @@ function formatTaskCollectionPayload(payload: Record): KnownPay return summary ? { title: 'Task list', text: summary } : null; } +function formatProcessCollectionPayload( + payload: Record +): KnownPayloadPreview | null { + const rawProcesses = + (Array.isArray(payload.processes) ? payload.processes : null) ?? + (Array.isArray(payload.items) ? payload.items : null); + if (rawProcesses) { + const processes = rawProcesses + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)); + const processSummaries = processes + .slice(0, 3) + .map((process) => { + const label = + stringField(process, 'label') ?? + stringField(process, 'name') ?? + stringField(process, 'command') ?? + stringField(process, 'pid'); + const status = stringField(process, 'status'); + if (label && status) return `${label} ${status}`; + return label ?? status; + }) + .filter(Boolean); + const remainingCount = Math.max(0, processes.length - processSummaries.length); + const moreText = remainingCount > 0 ? `; +${remainingCount} more` : ''; + const countText = `${processes.length} ${processes.length === 1 ? 'process' : 'processes'}`; + return { + title: 'Process list', + text: + processSummaries.length > 0 + ? `${countText} - ${processSummaries.join('; ')}${moreText}` + : countText, + }; + } + + const processCount = countArrayField(payload, ['processes', 'items']); + if (processCount != null) { + return { title: 'Process list', text: `${processCount} processes` }; + } + return null; +} + +function formatProcessCollectionArrayPayload(items: readonly unknown[]): KnownPayloadPreview { + const processes = items + .map((item) => asRecord(item)) + .filter((item): item is Record => Boolean(item)); + const processSummaries = processes + .slice(0, 3) + .map((process) => { + const label = + stringField(process, 'label') ?? + stringField(process, 'name') ?? + stringField(process, 'command') ?? + stringifyPrimitive(process.pid); + const status = stringField(process, 'status'); + if (label && status) return `${label} ${status}`; + return label || status; + }) + .filter(Boolean); + const remainingCount = Math.max(0, processes.length - processSummaries.length); + const moreText = remainingCount > 0 ? `; +${remainingCount} more` : ''; + const countText = `${processes.length} ${processes.length === 1 ? 'process' : 'processes'}`; + return { + title: 'Process list', + text: + processSummaries.length > 0 + ? `${countText} - ${processSummaries.join('; ')}${moreText}` + : countText, + }; +} + function formatRelationshipPayload( payload: Record, fallbackInput?: Record | null @@ -766,6 +845,19 @@ function formatTaskToolPayload( } if (taskRef) return { title: 'Task created', text: `Created ${taskRef}` }; } + if (canonical === 'task_add') { + if (taskRef && taskSummary) return { title: 'Task added', text: `${taskRef}: ${taskSummary}` }; + if (taskRef) return { title: 'Task added', text: `Added ${taskRef}` }; + } + if (canonical === 'task_update') { + if (taskRef && status) return { title: 'Task updated', text: `${taskRef} -> ${status}` }; + if (taskRef && taskSummary) + return { title: 'Task updated', text: `${taskRef}: ${taskSummary}` }; + if (taskRef) return { title: 'Task updated', text: `Updated ${taskRef}` }; + } + if (canonical === 'task_delete') { + return taskRef ? { title: 'Task deleted', text: `Deleted ${taskRef}` } : null; + } if (canonical === 'task_list' || canonical === 'task_briefing') { const collectionText = formatTaskCollectionPayload(payload); if (collectionText) { @@ -856,17 +948,38 @@ function formatRuntimePayload( fallbackInput?: Record | null ): KnownPayloadPreview | null { const canonical = canonicalToolNameValue ?? ''; + const memberName = + stringField(payload, 'memberName') ?? + stringField(payload, 'fromMemberName') ?? + stringField(fallbackInput ?? undefined, 'memberName') ?? + stringField(fallbackInput ?? undefined, 'fromMemberName'); if (canonical === 'runtime_bootstrap_checkin') { - const memberName = - stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); return { title: 'Runtime check-in', text: memberName ? `${memberName} checked in` : 'Runtime checked in', }; } + if (canonical === 'runtime_heartbeat') { + return { + title: 'Runtime heartbeat', + text: memberName ? `${memberName} heartbeat` : 'Runtime heartbeat', + }; + } + if (canonical === 'runtime_task_event') { + const taskRef = taskRefFromPayload(payload, fallbackInput); + const event = + stringField(payload, 'event') ?? + stringField(payload, 'kind') ?? + stringField(fallbackInput ?? undefined, 'event') ?? + stringField(fallbackInput ?? undefined, 'kind'); + const actor = memberName ? `${memberName} ` : ''; + if (taskRef && event) { + return { title: 'Runtime task event', text: `${actor}${event} ${taskRef}` }; + } + if (taskRef) return { title: 'Runtime task event', text: `${actor}${taskRef}` }; + if (event) return { title: 'Runtime task event', text: `${actor}${event}` }; + } if (canonical === 'member_briefing') { - const memberName = - stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); return { title: 'Member briefing', text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', @@ -875,6 +988,45 @@ function formatRuntimePayload( return null; } +function formatWorkSyncPayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if (canonical !== 'member_work_sync_status' && canonical !== 'member_work_sync_report') { + return null; + } + + const state = + stringField(payload, 'state') ?? + stringField(payload, 'status') ?? + stringField(fallbackInput ?? undefined, 'state') ?? + stringField(fallbackInput ?? undefined, 'status'); + const memberName = + stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); + const rawTaskIds = Array.isArray(payload.taskIds) + ? payload.taskIds + : Array.isArray(fallbackInput?.taskIds) + ? fallbackInput.taskIds + : []; + const taskRefs = [ + ...new Set( + rawTaskIds + .map((taskId) => (typeof taskId === 'string' ? formatTaskRef(taskId) : null)) + .filter((taskRef): taskRef is string => Boolean(taskRef)) + ), + ].slice(0, 3); + const taskText = taskRefs.length > 0 ? ` ${taskRefs.join(', ')}` : ''; + const memberText = memberName ? `${memberName} ` : ''; + const stateText = state ? `${state}${taskText}` : `updated${taskText}`; + + return { + title: canonical === 'member_work_sync_status' ? 'Work sync status' : 'Work sync report', + text: `${memberText}${stateText}`.trim(), + }; +} + function formatErrorPayload(payload: Record): KnownPayloadPreview | null { if (unknownPayloadLooksLikeError(payload)) { return { title: 'Tool error', text: payloadErrorMessage(payload) ?? 'Tool reported failure' }; @@ -973,6 +1125,19 @@ function formatPlainToolResultStatus( if (!toolContext) { return null; } + if (toolContext.canonicalName === 'member_briefing') { + const memberMatch = /^member briefing for\s+([^\s]+)\s+on team\b/i.exec( + compactWhitespace(value) + ); + const memberName = + memberMatch?.[1] ?? + stringField(asRecord(toolContext.input), 'memberName') ?? + stringField(asRecord(toolContext.input), 'member'); + return { + title: 'Member briefing', + text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', + }; + } const normalized = compactWhitespace(value).toLowerCase(); if (!['ok', 'done', 'success', 'comment added', 'message sent'].includes(normalized)) { return null; @@ -986,12 +1151,35 @@ function formatPlainToolResultStatus( const text = fallbackInput ? formatCrossTeamPayload(fallbackInput) : null; return text ? { title: 'Cross-team message', text } : null; } + if ( + toolContext.canonicalName === 'member_work_sync_status' || + toolContext.canonicalName === 'member_work_sync_report' + ) { + return formatWorkSyncPayload({}, toolContext.canonicalName, fallbackInput); + } return ( formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) ); } +function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null { + const compact = compactWhitespace(value); + if (!compact) { + return null; + } + + const looksLikeError = + /\bexecution failed\b/i.test(compact) || + /\bfailed without output\b/i.test(compact) || + /\btool\b[^.]{0,80}\bfailed\b/i.test(compact) || + /\btask not found\b/i.test(compact) || + /\bpermission denied\b/i.test(compact) || + /\b(error|exception|traceback)\s*:/i.test(compact); + + return looksLikeError ? { ...truncatePreview(compact, limit), title: 'Tool error' } : null; +} + function formatTaskToolInputPayload( canonicalToolNameValue: string, payload: Record @@ -1076,6 +1264,16 @@ function formatKnownPayloadPreview( if (runtimeText) { return runtimeText; } + const workSyncText = formatWorkSyncPayload(payload, canonical, fallbackInput); + if (workSyncText) { + return workSyncText; + } + if (canonical === 'process_list') { + const processText = formatProcessCollectionPayload(payload); + if (processText) { + return processText; + } + } if (canonical === 'cross_team_send') { const crossTeamText = formatCrossTeamPayload(payload); if (crossTeamText) { @@ -1113,6 +1311,10 @@ function previewUnknownValue( if (known) { return { ...truncatePreview(known.text, limit), title: known.title }; } + const plainError = formatPlainToolErrorText(value, limit); + if (plainError) { + return plainError; + } const plainStatus = formatPlainToolResultStatus(value, toolContext); if (plainStatus) { return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; @@ -1138,6 +1340,10 @@ function previewUnknownValue( if (knownCollection) { return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title }; } + if (toolContext?.canonicalName === 'process_list') { + const processCollection = formatProcessCollectionArrayPayload(value); + return { ...truncatePreview(processCollection.text, limit), title: processCollection.title }; + } const parts = value .slice(0, 3) .map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview) @@ -1192,6 +1398,14 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): } const payload = recordFromUnknown(value); if (payload) { + const runtimeFormatted = formatRuntimePayload(payload, canonical, payload); + if (runtimeFormatted) { + return truncatePreview(runtimeFormatted.text, limit); + } + const workSyncFormatted = formatWorkSyncPayload(payload, canonical, payload); + if (workSyncFormatted) { + return truncatePreview(workSyncFormatted.text, limit); + } const taskFormatted = formatTaskToolInputPayload(canonical, payload); if (taskFormatted) { return truncatePreview(taskFormatted, limit);