From cd1816f63bd7b2ffbcabef586be396067e739bee Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 14 Jul 2026 01:36:17 +0300 Subject: [PATCH] fix(chat): make task session rendering authoritative Use the Task part's state.metadata.sessionId as the only live identity contract for child sessions. Remove timestamp, status, and ordering heuristics that could misassociate concurrent subagents or repeatedly scan directory sessions while parts stream. Extract Task metadata parsing and child-summary projection into a focused model with cached projections for unchanged message records. Preserve output and part-level metadata parsing only for legacy persisted records, and keep standalone Task rows visible when sorted activity groups are rendered. Validated with focused Task and turn projection tests, UI type-check, lint, and dead-code analysis. --- .../components/chat/message/MessageBody.tsx | 2 +- .../chat/message/parts/DOCUMENTATION.md | 6 + .../chat/message/parts/ToolPart.tsx | 277 +----------------- .../resolveFallbackTaskSessionId.test.js | 256 ---------------- .../parts/resolveFallbackTaskSessionId.ts | 106 ------- .../chat/message/parts/taskToolModel.test.ts | 42 +++ .../chat/message/parts/taskToolModel.ts | 133 +++++++++ 7 files changed, 195 insertions(+), 627 deletions(-) delete mode 100644 packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js delete mode 100644 packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts create mode 100644 packages/ui/src/components/chat/message/parts/taskToolModel.test.ts create mode 100644 packages/ui/src/components/chat/message/parts/taskToolModel.ts diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 9843ed11..6563375f 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1839,7 +1839,7 @@ const AssistantMessageBody = React.memo(({ } const activity = activityByPart.get(part); - if (activity?.kind === 'tool' && (shouldRenderActivityGroup || !isStandaloneTool(toolName))) { + if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) { i += 1; continue; } diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 42147870..64c4058f 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -25,6 +25,12 @@ Use this doc when you ask an agent to change tool/header/description behavior. - Controls expandable header title/description/diff stats/timer and expanded output body. - If you want to change expandable tool layout, edit here. +- `taskToolModel.ts` + - Owns Task metadata parsing and child-session summary projection. + - `part.state.metadata.sessionId` is the only live identity contract between a Task and its child session. + - A running Task may briefly have no `sessionId`; render it as waiting until the authoritative part update arrives. Never match parallel children by order, title, timestamp, or status. + - Part-level metadata and output parsing exist only for older persisted records and never override state metadata. + - `toolPresentation.tsx` - Shared icon mapping for tool names (`getToolIcon`). - Used by both `ProgressiveGroup.tsx` and `ToolPart.tsx`. diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index dae16e3b..2245e90a 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -11,7 +11,7 @@ import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; +import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; import { useUIStore } from '@/stores/useUIStore'; import { sessionEvents } from '@/lib/sessionEvents'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; @@ -24,7 +24,6 @@ import type { ContentChangeReason } from '@/hooks/useChatAutoFollow'; import type { ToolPopupContent } from '../types'; import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; -import type { MessageRecord } from '@/lib/messageCompletion'; import { formatEditOutput, @@ -42,8 +41,15 @@ import { MinDurationShineText } from './MinDurationShineText'; import { ToolRevealOnMount } from './ToolRevealOnMount'; import { getToolIcon } from './toolPresentation'; import { useDurationTickerNow } from './useDurationTicker'; -import { resolveFallbackTaskSessionId } from './resolveFallbackTaskSessionId'; -import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; +import { + buildTaskSummaryEntriesFromSession, + normalizeTaskSummaryEntries, + parseTaskMetadataBlock, + readTaskSessionIdFromOutput, + readTaskSessionIdFromRecord, + stripTaskMetadataFromOutput, + type TaskToolSummaryEntry, +} from './taskToolModel'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils'; @@ -164,7 +170,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => { }; const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap -const TASK_TOOL_FALLBACK_RETRY_MS = 3000; const GIT_REFRESH_MUTATING_TOOLS = new Set([ 'bash', 'edit', @@ -965,94 +970,6 @@ const ToolScrollableTextOutput: React.FC<{ ToolScrollableTextOutput.displayName = 'ToolScrollableTextOutput'; -type TaskToolSummaryEntry = { - id?: string; - tool?: string; - state?: { - status?: string; - title?: string; - input?: Record; - }; -}; - -type SessionMessageWithParts = MessageRecord; - -const normalizeSessionIdCandidate = (value: unknown): string | undefined => { - if (typeof value !== 'string') { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -}; - -const readTaskSessionIdFromRecord = (value: unknown): string | undefined => { - if (!value || typeof value !== 'object') { - return undefined; - } - - const record = value as Record; - return ( - normalizeSessionIdCandidate(record.sessionID) - ?? normalizeSessionIdCandidate(record.sessionId) - ); -}; - -const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => { - if (typeof output !== 'string' || output.trim().length === 0) { - return undefined; - } - const parsedMetadata = parseTaskMetadataBlock(output); - if (parsedMetadata.sessionId) { - return parsedMetadata.sessionId; - } - const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i); - const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i); - const candidate = taskMatch?.[1] ?? sessionMatch?.[1]; - if (candidate) { - return normalizeSessionIdCandidate(candidate); - } - - // OpenCode tool output may wrap child session id in - const taskTagSessionId = readTaskTagSessionIdFromOutput(output); - if (taskTagSessionId) { - return normalizeSessionIdCandidate(taskTagSessionId); - } - return undefined; -}; - -const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[]): TaskToolSummaryEntry[] => { - const entries: TaskToolSummaryEntry[] = []; - - for (const message of messages) { - if (message?.info?.role !== 'assistant') { - continue; - } - const parts = Array.isArray(message.parts) ? message.parts : []; - for (const part of parts) { - if (part?.type !== 'tool') { - continue; - } - const toolName = normalizeToolName(part.tool); - if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') { - continue; - } - const partState = part.state as { status?: string; title?: string; input?: unknown } | undefined; - entries.push({ - id: part.id, - tool: part.tool, - state: { - status: partState?.status, - title: partState?.title, - input: partState?.input && typeof partState.input === 'object' - ? (partState.input as Record) - : undefined, - }, - }); - } - } - - return entries; -}; const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { const title = entry.state?.title; @@ -1249,105 +1166,6 @@ const TaskSummaryEntriesList = React.memo(({ TaskSummaryEntriesList.displayName = 'TaskSummaryEntriesList'; -const stripTaskMetadataFromOutput = (output: string): string => { - // Strip only a trailing ... block. - return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); -}; - -const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => { - if (!Array.isArray(value)) { - return []; - } - - const normalized: TaskToolSummaryEntry[] = []; - for (const entry of value) { - if (typeof entry === 'string') { - normalized.push({ - tool: 'tool', - state: { status: 'completed', title: entry }, - }); - continue; - } - - if (!entry || typeof entry !== 'object') { - continue; - } - - const record = entry as { - id?: unknown; - tool?: unknown; - title?: unknown; - status?: unknown; - state?: { status?: unknown; title?: unknown; input?: unknown }; - }; - - const stateStatus = typeof record.state?.status === 'string' ? record.state.status : undefined; - const stateTitle = typeof record.state?.title === 'string' ? record.state.title : undefined; - const status = stateStatus ?? (typeof record.status === 'string' ? record.status : undefined); - const title = stateTitle ?? (typeof record.title === 'string' ? record.title : undefined); - - normalized.push({ - id: typeof record.id === 'string' ? record.id : undefined, - tool: typeof record.tool === 'string' ? record.tool : 'tool', - state: { - status, - title, - input: record.state?.input && typeof record.state.input === 'object' - ? (record.state.input as Record) - : undefined, - }, - }); - } - - return normalized; -}; - -const parseTaskMetadataBlock = (output: string | undefined): { - sessionId?: string; - summaryEntries: TaskToolSummaryEntry[]; -} => { - if (typeof output !== 'string' || output.trim().length === 0) { - return { summaryEntries: [] }; - } - - const blockMatch = output.match(/\s*([\s\S]*?)\s*<\/task_metadata>/i); - if (!blockMatch?.[1]) { - return { summaryEntries: [] }; - } - - const raw = blockMatch[1].trim(); - if (!raw) { - return { summaryEntries: [] }; - } - - try { - const parsed = JSON.parse(raw) as { - sessionId?: unknown; - sessionID?: unknown; - summary?: unknown; - entries?: unknown; - tools?: unknown; - calls?: unknown; - }; - - const summaryEntries = normalizeTaskSummaryEntries( - parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls - ); - - const sessionId = - (typeof parsed.sessionId === 'string' && parsed.sessionId.trim().length > 0 - ? parsed.sessionId.trim() - : undefined) ?? - (typeof parsed.sessionID === 'string' && parsed.sessionID.trim().length > 0 - ? parsed.sessionID.trim() - : undefined); - - return { sessionId, summaryEntries }; - } catch { - return { summaryEntries: [] }; - } -}; - const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; @@ -2126,7 +1944,6 @@ const ToolPartContent: React.FC = ({ const state = part.state; const showToolFileIcons = useUIStore((s) => s.showToolFileIcons); const currentDirectory = useEffectiveDirectory() ?? ''; - const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const normalizedPartTool = normalizeToolName(part.tool); const isTaskTool = normalizedPartTool === 'task'; @@ -2258,16 +2075,6 @@ const ToolPartContent: React.FC = ({ return Math.min(...candidates); }, [localStartAt, pinnedTime.start, time?.start]); - const taskSessionResolutionStart = React.useMemo(() => { - if (typeof pinnedTime.start === 'number') { - return pinnedTime.start; - } - if (typeof time?.start === 'number') { - return time.start; - } - return localStartAt; - }, [localStartAt, pinnedTime.start, time?.start]); - const taskOutputString = React.useMemo(() => { return typeof stateWithData.output === 'string' ? stateWithData.output : undefined; }, [stateWithData.output]); @@ -2276,10 +2083,6 @@ const ToolPartContent: React.FC = ({ return parseTaskMetadataBlock(taskOutputString); }, [taskOutputString]); - // Track whether fallback session resolution has failed at least once. - // When true, resolveFallbackTaskSessionId widens its time window (3s → 8s). - const [taskFallbackRetried, setTaskFallbackRetried] = React.useState(false); - const metadataTaskSummaryEntries = React.useMemo(() => { if (!isTaskTool) { return []; @@ -2298,11 +2101,13 @@ const ToolPartContent: React.FC = ({ const hasFinalMetadataTaskSummary = isFinalized && metadataTaskSummaryEntries.length > 0; - const explicitTaskSessionId = React.useMemo(() => { + const taskSessionId = React.useMemo(() => { if (!isTaskTool) { return undefined; } + // Current OpenCode publishes this authoritative join while the Task is + // running. The remaining sources only support older persisted parts. const metadataSessionId = readTaskSessionIdFromRecord(metadata); if (metadataSessionId) { return metadataSessionId; @@ -2319,25 +2124,6 @@ const ToolPartContent: React.FC = ({ return readTaskSessionIdFromOutput(taskOutputString); }, [isTaskTool, metadata, parsedTaskMetadata.sessionId, partMetadata, taskOutputString]); - const fallbackTaskSessionId = useDirectorySync( - React.useCallback((storeState) => { - if (explicitTaskSessionId) { - return undefined; - } - - return resolveFallbackTaskSessionId({ - isTaskTool, - parentSessionId: currentSessionId ?? undefined, - taskStartTime: taskSessionResolutionStart, - sessions: storeState.session, - sessionStatusMap: storeState.session_status, - hasRetried: taskFallbackRetried, - }); - }, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, taskFallbackRetried]), - currentDirectory, - ); - - const taskSessionId = explicitTaskSessionId ?? fallbackTaskSessionId; const childSessionLookupId = hasFinalMetadataTaskSummary ? '' : (taskSessionId ?? ''); const childSessionMessages = useSessionMessageRecords(childSessionLookupId, currentDirectory); @@ -2353,43 +2139,6 @@ const ToolPartContent: React.FC = ({ return buildTaskSummaryEntriesFromSession(childSessionMessages); }, [childSessionMessages, isTaskTool, taskSessionId]); - React.useEffect(() => { - setTaskFallbackRetried(false); - }, [taskSessionId]); - - // Widen fallback resolution window only after a real retry boundary. - React.useEffect(() => { - if (!isTaskTool || taskFallbackRetried || explicitTaskSessionId != null || taskSessionId != null || isFinalized) { - return; - } - - const sinceStart = - typeof taskSessionResolutionStart === 'number' - ? Date.now() - taskSessionResolutionStart - : 0; - const delay = Math.max(0, TASK_TOOL_FALLBACK_RETRY_MS - sinceStart); - - if (typeof window === 'undefined') { - setTaskFallbackRetried(true); - return; - } - - const timer = window.setTimeout(() => { - setTaskFallbackRetried(true); - }, delay); - - return () => { - window.clearTimeout(timer); - }; - }, [ - explicitTaskSessionId, - isFinalized, - isTaskTool, - taskFallbackRetried, - taskSessionId, - taskSessionResolutionStart, - ]); - React.useEffect(() => { if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') { setLocalFinalizedAt(undefined); diff --git a/packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js b/packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js deleted file mode 100644 index 947e6315..00000000 --- a/packages/ui/src/components/chat/message/parts/__tests__/resolveFallbackTaskSessionId.test.js +++ /dev/null @@ -1,256 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { resolveFallbackTaskSessionId } from '../resolveFallbackTaskSessionId'; - -const busyStatus = { type: 'busy' }; -const retryStatus = { type: 'retry', attempt: 1, message: '', next: Date.now() + 5000 }; - -const makeSession = (overrides) => ({ - slug: overrides.id, - projectID: 'proj', - directory: '/test', - title: overrides.title ?? `Session ${overrides.id}`, - version: '1', - time: { - created: overrides.time?.created ?? Date.now(), - updated: overrides.time?.updated ?? Date.now(), - }, - ...overrides, -}); - -describe('resolveFallbackTaskSessionId', () => { - const parentSessionId = 'parent-session-1'; - const taskStartTime = 1000000; - - it('returns undefined when not a task tool', () => { - const result = resolveFallbackTaskSessionId({ - isTaskTool: false, - parentSessionId, - taskStartTime, - sessions: [], - }); - expect(result).toBeUndefined(); - }); - - it('returns undefined when multiple idle candidates are ambiguous', () => { - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [ - makeSession({ id: 'child-a', parentID: parentSessionId, time: { created: taskStartTime + 100 } }), - makeSession({ id: 'child-b', parentID: parentSessionId, time: { created: taskStartTime + 200 } }), - ], - sessionStatusMap: {}, - }); - expect(result).toBeUndefined(); - }); - - it('returns undefined when parentSessionId is missing', () => { - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId: undefined, - taskStartTime, - sessions: [], - }); - expect(result).toBeUndefined(); - }); - - it('returns undefined when no sessions exist', () => { - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [], - }); - expect(result).toBeUndefined(); - }); - - it('returns the child session id when exactly one child matches parent and time', () => { - const child = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child], - }); - expect(result).toBe('child-1'); - }); - - it('returns undefined when child was created before task start', () => { - const child = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime - 1, updated: taskStartTime - 1 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child], - }); - expect(result).toBeUndefined(); - }); - - it('returns undefined when child was created too long after task start', () => { - const child = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime + 5000, updated: taskStartTime + 5000 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child], - }); - expect(result).toBeUndefined(); - }); - - it('returns undefined when multiple children match and are ambiguous', () => { - const child1 = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - const child2 = makeSession({ - id: 'child-2', - parentID: parentSessionId, - time: { created: taskStartTime + 200, updated: taskStartTime + 200 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child1, child2], - }); - expect(result).toBeUndefined(); - }); - - it('returns the busy child when multiple children match but only one is busy', () => { - const child1 = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - const child2 = makeSession({ - id: 'child-2', - parentID: parentSessionId, - time: { created: taskStartTime + 200, updated: taskStartTime + 200 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child1, child2], - sessionStatusMap: { - 'child-2': busyStatus, - }, - }); - expect(result).toBe('child-2'); - }); - - it('returns undefined when multiple children are both busy (ambiguous)', () => { - const child1 = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - const child2 = makeSession({ - id: 'child-2', - parentID: parentSessionId, - time: { created: taskStartTime + 200, updated: taskStartTime + 200 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child1, child2], - sessionStatusMap: { - 'child-1': busyStatus, - 'child-2': busyStatus, - }, - }); - expect(result).toBeUndefined(); - }); - - it('ignores sessions with different parentID', () => { - const child = makeSession({ - id: 'child-1', - parentID: 'other-parent', - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child], - }); - expect(result).toBeUndefined(); - }); - - it('ignores sessions without parentID', () => { - const child = makeSession({ - id: 'child-1', - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child], - }); - expect(result).toBeUndefined(); - }); - - it('prefers exactly one live candidate (retry status) over ambiguous total', () => { - const child1 = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: taskStartTime + 100, updated: taskStartTime + 100 }, - }); - const child2 = makeSession({ - id: 'child-2', - parentID: parentSessionId, - time: { created: taskStartTime + 200, updated: taskStartTime + 200 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime, - sessions: [child1, child2], - sessionStatusMap: { - 'child-1': retryStatus, - }, - }); - expect(result).toBe('child-1'); - }); - - it('returns undefined when taskStartTime is undefined', () => { - const child = makeSession({ - id: 'child-1', - parentID: parentSessionId, - time: { created: 100, updated: 100 }, - }); - - const result = resolveFallbackTaskSessionId({ - isTaskTool: true, - parentSessionId, - taskStartTime: undefined, - sessions: [child], - }); - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts b/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts deleted file mode 100644 index dc685a0f..00000000 --- a/packages/ui/src/components/chat/message/parts/resolveFallbackTaskSessionId.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * resolveFallbackTaskSessionId — pure helper that resolves a pending task tool - * to a child session from the directory session store when explicit taskSessionId - * metadata is delayed. - * - * Conservative: only returns a session id when the match is unambiguous. - */ - -import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client'; - -/** - * Fallback is intentionally narrow: only sessions created shortly after the - * task started are eligible. This avoids binding to earlier or later sibling - * subagent sessions when explicit task metadata is delayed. - */ -/** - * Narrow initial window avoids binding to wrong sessions on first attempt. - * Wide window on retry handles late-appearing child sessions under load. - */ -const TASK_SESSION_MATCH_WINDOW_MS = 3000; -const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000; - -const LIVE_STATUSES = new Set(['busy', 'retry']); - -export interface ResolveFallbackParams { - /** True when this tool is a task tool */ - isTaskTool: boolean; - /** The parent session id (current session) */ - parentSessionId: string | undefined; - /** When the task tool started (ms timestamp) */ - taskStartTime: number | undefined; - /** Sessions from the directory store */ - sessions: Session[]; - /** Session status map from the sync store */ - sessionStatusMap?: Record; - /** True when a previous resolution attempt has already failed (enables wider window) */ - hasRetried?: boolean; -} - -/** - * Attempts to resolve a child session id for a pending task tool by matching - * against sessions in the directory store. - * - * Returns `undefined` when: - * - Not a task tool - * - Parent session is unknown - * - Task start time is unknown - * - No unambiguous match found - */ -export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined { - const { - isTaskTool, - parentSessionId, - taskStartTime, - sessions, - sessionStatusMap, - hasRetried = false, - } = params; - - if (!isTaskTool || !parentSessionId) { - return undefined; - } - - if (typeof taskStartTime !== 'number') { - return undefined; - } - - // Filter candidate sessions: parentID matches the current session. - let candidates = sessions.filter((session) => { - if (!session?.id || session.parentID !== parentSessionId) { - return false; - } - return true; - }); - - // Apply the time window even while running. Without it, a newly rendered task - // can briefly bind to the previous child session before its own child exists. - const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS; - const latestAllowed = taskStartTime + windowMs; - candidates = candidates.filter((session) => { - const created = session.time?.created; - return typeof created === 'number' && created >= taskStartTime && created <= latestAllowed; - }); - - if (candidates.length === 0) { - return undefined; - } - - // If exactly one candidate, return it regardless of status - if (candidates.length === 1) { - return candidates[0].id; - } - - // Multiple candidates: try to disambiguate by finding exactly one live (busy/retry) - const liveCandidates = candidates.filter((session) => { - const status = sessionStatusMap?.[session.id]; - return status != null && LIVE_STATUSES.has(status.type); - }); - - if (liveCandidates.length === 1) { - return liveCandidates[0].id; - } - - // Ambiguous — do not guess - return undefined; -} diff --git a/packages/ui/src/components/chat/message/parts/taskToolModel.test.ts b/packages/ui/src/components/chat/message/parts/taskToolModel.test.ts new file mode 100644 index 00000000..b5c1afb5 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/taskToolModel.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import type { Message, Part } from '@opencode-ai/sdk/v2'; + +import { + buildTaskSummaryEntriesFromSession, + parseTaskMetadataBlock, + readTaskSessionIdFromRecord, + readTaskSessionIdFromOutput, +} from './taskToolModel'; + +describe('taskToolModel', () => { + test('reads the current OpenCode running-state identity contract', () => { + expect(readTaskSessionIdFromRecord({ sessionId: 'child-live' })).toBe('child-live'); + expect(readTaskSessionIdFromRecord({})).toBe(undefined); + }); + + test('reads authoritative session and summary metadata', () => { + const output = 'result\n{"sessionID":"child-1","calls":[{"id":"tool-1","tool":"read","title":"a.ts"}]}'; + expect(parseTaskMetadataBlock(output)).toEqual({ + sessionId: 'child-1', + summaryEntries: [{ id: 'tool-1', tool: 'read', state: { status: undefined, title: 'a.ts', input: undefined } }], + }); + expect(readTaskSessionIdFromOutput(output)).toBe('child-1'); + }); + + test('projects tool calls while excluding nested task and todo bookkeeping', () => { + const message = { + info: { id: 'message-1', role: 'assistant' } as Message, + parts: [ + { id: 'read-1', type: 'tool', tool: 'read', state: { status: 'completed', input: { filePath: 'a.ts' } } }, + { id: 'task-1', type: 'tool', tool: 'task', state: { status: 'running' } }, + { id: 'todo-1', type: 'tool', tool: 'todowrite', state: { status: 'completed' } }, + ] as unknown as Part[], + }; + + expect(buildTaskSummaryEntriesFromSession([message])).toEqual([{ + id: 'read-1', + tool: 'read', + state: { status: 'completed', title: undefined, input: { filePath: 'a.ts' } }, + }]); + }); +}); diff --git a/packages/ui/src/components/chat/message/parts/taskToolModel.ts b/packages/ui/src/components/chat/message/parts/taskToolModel.ts new file mode 100644 index 00000000..76210f87 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/taskToolModel.ts @@ -0,0 +1,133 @@ +import type { MessageRecord } from '@/lib/messageCompletion'; + +import { readTaskTagSessionIdFromOutput } from './taskSessionIdParser'; + +export type TaskToolSummaryEntry = { + id?: string; + tool?: string; + state?: { + status?: string; + title?: string; + input?: Record; + }; +}; + +const normalizeSessionIdCandidate = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +export const readTaskSessionIdFromRecord = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + return normalizeSessionIdCandidate(record.sessionID) ?? normalizeSessionIdCandidate(record.sessionId); +}; + +export const normalizeTaskSummaryEntries = (value: unknown): TaskToolSummaryEntry[] => { + if (!Array.isArray(value)) return []; + + const normalized: TaskToolSummaryEntry[] = []; + for (const entry of value) { + if (typeof entry === 'string') { + normalized.push({ tool: 'tool', state: { status: 'completed', title: entry } }); + continue; + } + if (!entry || typeof entry !== 'object') continue; + + const record = entry as { + id?: unknown; + tool?: unknown; + title?: unknown; + status?: unknown; + state?: { status?: unknown; title?: unknown; input?: unknown }; + }; + normalized.push({ + id: typeof record.id === 'string' ? record.id : undefined, + tool: typeof record.tool === 'string' ? record.tool : 'tool', + state: { + status: typeof record.state?.status === 'string' + ? record.state.status + : typeof record.status === 'string' ? record.status : undefined, + title: typeof record.state?.title === 'string' + ? record.state.title + : typeof record.title === 'string' ? record.title : undefined, + input: record.state?.input && typeof record.state.input === 'object' + ? record.state.input as Record + : undefined, + }, + }); + } + return normalized; +}; + +export const parseTaskMetadataBlock = (output: string | undefined): { + sessionId?: string; + summaryEntries: TaskToolSummaryEntry[]; +} => { + if (typeof output !== 'string' || output.trim().length === 0) return { summaryEntries: [] }; + const blockMatch = output.match(/\s*([\s\S]*?)\s*<\/task_metadata>/i); + if (!blockMatch?.[1]) return { summaryEntries: [] }; + + try { + const parsed = JSON.parse(blockMatch[1].trim()) as Record; + return { + sessionId: normalizeSessionIdCandidate(parsed.sessionId) ?? normalizeSessionIdCandidate(parsed.sessionID), + summaryEntries: normalizeTaskSummaryEntries(parsed.summary ?? parsed.entries ?? parsed.tools ?? parsed.calls), + }; + } catch { + return { summaryEntries: [] }; + } +}; + +export const readTaskSessionIdFromOutput = (output: string | undefined): string | undefined => { + if (typeof output !== 'string' || output.trim().length === 0) return undefined; + const parsedMetadata = parseTaskMetadataBlock(output); + if (parsedMetadata.sessionId) return parsedMetadata.sessionId; + + const taskMatch = output.match(/task_id\s*:\s*([^\s<"']+)/i); + const sessionMatch = output.match(/session[_\s-]?id\s*:\s*([^\s<"']+)/i); + const candidate = taskMatch?.[1] ?? sessionMatch?.[1]; + if (candidate) return normalizeSessionIdCandidate(candidate); + return normalizeSessionIdCandidate(readTaskTagSessionIdFromOutput(output)); +}; + +const messageSummaryCache = new WeakMap(); + +const projectMessageSummaryEntries = (message: MessageRecord): TaskToolSummaryEntry[] => { + const cached = messageSummaryCache.get(message); + if (cached) return cached; + + const entries: TaskToolSummaryEntry[] = []; + if (message.info.role === 'assistant') { + for (const part of message.parts) { + if (part.type !== 'tool') continue; + const toolName = part.tool?.trim().toLowerCase(); + if (!toolName || toolName === 'task' || toolName === 'todowrite' || toolName === 'todoread') continue; + const state = part.state as { status?: string; title?: string; input?: unknown } | undefined; + entries.push({ + id: part.id, + tool: part.tool, + state: { + status: state?.status, + title: state?.title, + input: state?.input && typeof state.input === 'object' + ? state.input as Record + : undefined, + }, + }); + } + } + messageSummaryCache.set(message, entries); + return entries; +}; + +export const buildTaskSummaryEntriesFromSession = (messages: MessageRecord[]): TaskToolSummaryEntry[] => { + const entries: TaskToolSummaryEntry[] = []; + for (const message of messages) entries.push(...projectMessageSummaryEntries(message)); + return entries; +}; + +export const stripTaskMetadataFromOutput = (output: string): string => { + return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); +};