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