diff --git a/src/renderer/src/workspace/claude/history.ts b/src/providers/claude/renderer/transcript/history.ts similarity index 100% rename from src/renderer/src/workspace/claude/history.ts rename to src/providers/claude/renderer/transcript/history.ts diff --git a/src/providers/claude/renderer/transcript/mapper.ts b/src/providers/claude/renderer/transcript/mapper.ts new file mode 100644 index 000000000..e23e04b0b --- /dev/null +++ b/src/providers/claude/renderer/transcript/mapper.ts @@ -0,0 +1,68 @@ +// Claude transcript-entry mapper — the provider-owned half of JSONL +// ingestion (#394 phase 2b). +// +// WHY this exists: the exact same "unwrap progress wrapper → filter to +// conversation/compact entries → derive uuid marker" sequence was +// duplicated in FOUR places (live ingest, initial history, older +// history, session preview), each hand-forked against the codex +// equivalent. The mapper owns the provider-specific mapping; the call +// sites keep their genuinely site-specific policies (dedupe, tool +// indexing, marker-update rules, optimistic reconciliation). +// +// The Claude mapper is stateless — Claude entries carry stable uuids +// and no cross-entry turn cursor. The TranscriptEntryMapper interface +// still has the cursor methods (no-ops here) because Codex's mapper +// needs them and the call sites treat mappers uniformly. + +import type { Entry } from '@shared/types/transcript' +import { + isCompactBoundaryEntry, + isCompactSummaryEntry, + isConversationEntry, +} from '@shared/types/transcript' +import type { + MappedTranscriptEntry, + TranscriptEntryMapper, +} from '@shared/types/providerConfig' +import { + claudeHistoryMarker, + extractEmbeddedClaudeProgressEntry, +} from './history' + +export function createClaudeTranscriptEntryMapper(): TranscriptEntryMapper { + return { + map(raw: Record): MappedTranscriptEntry { + const feedEntry = extractEmbeddedClaudeProgressEntry(raw) ?? (raw as Entry) + const marker = claudeHistoryMarker(raw) + // The conversation/compact filter lives HERE (not at call sites) + // so all four ingestion paths inherit identical semantics — the + // old duplication had already drifted (live ingest cleared + // pendingCompaction on compact summaries, bootstrap didn't; the + // caller keeps that decision, but what COUNTS as a feed entry is + // provider truth and belongs to the provider). + if ( + !isConversationEntry(feedEntry) && + !isCompactBoundaryEntry(feedEntry) && + !isCompactSummaryEntry(feedEntry) + ) { + return { entries: [], historyMarker: marker } + } + return { entries: [feedEntry], historyMarker: marker } + }, + // Claude has no rolling turn cursor — uuids are stable per entry. + getTurnCursor: () => null, + setTurnCursor: () => {}, + } +} + +/** + * Pass-A identity capture: which durable provider session id does this + * raw line claim? Claude stamps `sessionId` on (almost) every entry. + */ +export function extractClaudeProviderSessionId( + raw: Record, +): string | null { + return typeof raw.sessionId === 'string' && raw.sessionId.length > 0 + ? raw.sessionId + : null +} diff --git a/src/renderer/src/workspace/codex/entries.ts b/src/providers/codex/renderer/transcript/entries.ts similarity index 100% rename from src/renderer/src/workspace/codex/entries.ts rename to src/providers/codex/renderer/transcript/entries.ts diff --git a/src/renderer/src/workspace/codex/eventCursor.ts b/src/providers/codex/renderer/transcript/eventCursor.ts similarity index 100% rename from src/renderer/src/workspace/codex/eventCursor.ts rename to src/providers/codex/renderer/transcript/eventCursor.ts diff --git a/src/providers/codex/renderer/transcript/mapper.ts b/src/providers/codex/renderer/transcript/mapper.ts new file mode 100644 index 000000000..4b8892240 --- /dev/null +++ b/src/providers/codex/renderer/transcript/mapper.ts @@ -0,0 +1,74 @@ +// Codex transcript-entry mapper (#394 phase 2b). Counterpart of +// @providers/claude/renderer/transcript/mapper.ts — see that file's +// header for why the mapper capability exists. +// +// The Codex mapper is STATEFUL: rollout lines don't carry their turn +// id individually, so a rolling cursor (updated by turn_context lines +// and event payloads, cleared by terminal events) stamps each mapped +// entry. The exact sequence below is byte-for-byte the logic that was +// previously triplicated in useIpcSubscriptions (live), initialHistory +// (bootstrap) and history (older pagination): +// 1. turn_context rollout id updates the cursor +// 2. event payload turn id updates the cursor +// 3. map + stamp with the CURRENT cursor +// 4. terminal events (task_complete/turn_complete/turn_aborted) +// clear the cursor AFTER the line itself was stamped +// Order matters: a task_complete event must itself be attributed to +// the turn it completes, and the NEXT line starts unattributed. + +import type { + MappedTranscriptEntry, + TranscriptEntryMapper, +} from '@shared/types/providerConfig' +import { + codexHistoryMarker, + codexTurnIdFromRollout, + mapCodexRolloutToFeedEntries, + stampCodexTurnId, +} from './rollout' +import { codexEventType, codexTurnIdFromEventPayload } from './eventCursor' + +export function createCodexTranscriptEntryMapper( + initialTurnCursor: string | null = null, +): TranscriptEntryMapper { + let turnCursor = initialTurnCursor + return { + map(raw: Record): MappedTranscriptEntry { + const turnContextId = codexTurnIdFromRollout(raw) + if (turnContextId !== null) turnCursor = turnContextId + const payloadTurnId = codexTurnIdFromEventPayload(raw) + if (payloadTurnId !== null) turnCursor = payloadTurnId + + const entries = mapCodexRolloutToFeedEntries(raw).map(entry => + stampCodexTurnId(entry, turnCursor), + ) + const marker = codexHistoryMarker(raw) + + const eventType = codexEventType(raw) + if ( + eventType === 'task_complete' || + eventType === 'turn_complete' || + eventType === 'turn_aborted' + ) { + turnCursor = null + } + + return { entries, historyMarker: marker } + }, + // The live-ingest site persists the cursor across bursts (it used + // to live in a module-level Map keyed by sessionId); history / + // preview sites start each chunk with a null cursor, matching the + // old local-variable behavior. + getTurnCursor: () => turnCursor, + setTurnCursor: (id: string | null) => { + turnCursor = id + }, + } +} + +/** + * Pass-A identity capture: Codex's durable session id arrives on the + * session_meta line's payload.id. Re-exported through the mapper module + * so the registry capability table imports one file per provider. + */ +export { extractCodexProviderSessionId } from './entries' diff --git a/src/renderer/src/workspace/codex/rollout.test.ts b/src/providers/codex/renderer/transcript/rollout.test.ts similarity index 94% rename from src/renderer/src/workspace/codex/rollout.test.ts rename to src/providers/codex/renderer/transcript/rollout.test.ts index 07f20fbb6..a8df6a579 100644 --- a/src/renderer/src/workspace/codex/rollout.test.ts +++ b/src/providers/codex/renderer/transcript/rollout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { mapCodexRolloutToFeedEntries } from '@renderer/workspace/codex/rollout' +import { mapCodexRolloutToFeedEntries } from '@providers/codex/renderer/transcript/rollout' function userMessage(text: string): Record { return { diff --git a/src/renderer/src/workspace/codex/rollout.ts b/src/providers/codex/renderer/transcript/rollout.ts similarity index 99% rename from src/renderer/src/workspace/codex/rollout.ts rename to src/providers/codex/renderer/transcript/rollout.ts index 2a06a5245..a98dc63fd 100644 --- a/src/renderer/src/workspace/codex/rollout.ts +++ b/src/providers/codex/renderer/transcript/rollout.ts @@ -9,7 +9,7 @@ import { isCodexExecWrapperOutput, parseCodexJson, stripCodexExecWrapper, -} from '@renderer/workspace/codex/entries' +} from '@providers/codex/renderer/transcript/entries' // Codex rollout → feed entry mapping. // diff --git a/src/providers/registry.renderer.capabilities.ts b/src/providers/registry.renderer.capabilities.ts index d50a4db0f..66d2019ad 100644 --- a/src/providers/registry.renderer.capabilities.ts +++ b/src/providers/registry.renderer.capabilities.ts @@ -12,6 +12,15 @@ import { renderCodexToolResult, renderCodexToolUse, } from '@providers/codex/renderer/rows/dispatch' +import type { TranscriptEntryMapper } from '@shared/types/providerConfig' +import { + createClaudeTranscriptEntryMapper, + extractClaudeProviderSessionId, +} from '@providers/claude/renderer/transcript/mapper' +import { + createCodexTranscriptEntryMapper, + extractCodexProviderSessionId, +} from '@providers/codex/renderer/transcript/mapper' export type RendererProviderCapabilities = { id: AgentProviderKind @@ -22,6 +31,21 @@ export type RendererProviderCapabilities = { block: ToolResultBlock, context: { sourceTool?: ToolUseBlock | null }, ) => ReactNode | undefined + /** + * Provider-owned transcript-line → feed-entry mapping (#394 phase + * 2b). One fresh mapper per ingestion stream; see the + * TranscriptEntryMapper docstring in providerConfig.ts for the + * stateful-cursor rationale. Replaced the quadruplicated + * per-provider mapping loops in useIpcSubscriptions / + * initialHistory / history / previewModel. + */ + createTranscriptEntryMapper: (initialTurnCursor?: string | null) => TranscriptEntryMapper + /** + * Pass-A identity capture: extract the durable provider session id + * this raw line claims, or null. Claude: `sessionId` on any entry; + * Codex: session_meta `payload.id`. + */ + extractProviderSessionId: (raw: Record) => string | null } const claudeCapabilities: RendererProviderCapabilities = { @@ -30,6 +54,8 @@ const claudeCapabilities: RendererProviderCapabilities = { conditionViews: CLAUDE_VIEWS, renderToolUse: renderClaudeToolUse, renderToolResult: renderClaudeToolResult, + createTranscriptEntryMapper: () => createClaudeTranscriptEntryMapper(), + extractProviderSessionId: extractClaudeProviderSessionId, } const codexCapabilities: RendererProviderCapabilities = { @@ -38,6 +64,9 @@ const codexCapabilities: RendererProviderCapabilities = { conditionViews: CODEX_VIEWS, renderToolUse: renderCodexToolUse, renderToolResult: renderCodexToolResult, + createTranscriptEntryMapper: (initialTurnCursor) => + createCodexTranscriptEntryMapper(initialTurnCursor ?? null), + extractProviderSessionId: extractCodexProviderSessionId, } const rendererProviderCapabilities: Record = { diff --git a/src/renderer/src/features/session-preview/previewModel.ts b/src/renderer/src/features/session-preview/previewModel.ts index bb51a6f74..f0da91e67 100644 --- a/src/renderer/src/features/session-preview/previewModel.ts +++ b/src/renderer/src/features/session-preview/previewModel.ts @@ -5,12 +5,10 @@ import type { ToolUseBlock, } from '@shared/types/transcript' import { - isCompactBoundaryEntry, isCompactSummaryEntry, isConversationEntry, } from '@shared/types/transcript' -import { mapCodexRolloutToFeedEntries } from '@renderer/workspace/codex/rollout' -import { extractEmbeddedClaudeProgressEntry } from '@renderer/workspace/claude/history' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { indexEntryIntoMaps } from '@renderer/workspace/entries/utils' // previewModel — turn a raw JSONL transcript tail into the exact inputs @@ -65,28 +63,19 @@ export function buildPreviewModel( const toolUseIndex = new Map() const toolResultIndex = new Map() + // Registry-owned mapper (#394 phase 2b): the per-provider mapping + // (Codex rollout fan-out + turn stamping, Claude progress-unwrap + + // conversation/compact filter) lives on the provider capability, so + // the preview and every live ingestion path agree on which lines are + // content by construction rather than by four hand-synced copies. + // The preview keeps its documented simplifications — no dedupe, no + // markers, no ghosts (the tail is a single contiguous chunk). + const mapper = getRendererProviderCapabilities(kind).createTranscriptEntryMapper() + for (const raw of rawEntries) { - if (kind === 'codex') { - // One rollout line can fan out to several feed entries (a - // `compacted` line yields a boundary + summary + replacement - // history). Index each so tool pairing works. - for (const mapped of mapCodexRolloutToFeedEntries(raw)) { - entries.push(mapped) - indexEntryIntoMaps(mapped, toolUseIndex, toolResultIndex) - } - continue - } - // Claude: unwrap a live progress wrapper if present, else treat the - // raw line as an Entry. Same filter the live history loader applies - // so the preview and the feed agree on which lines are content. - const feedEntry = extractEmbeddedClaudeProgressEntry(raw) ?? (raw as Entry) - if ( - isConversationEntry(feedEntry) || - isCompactBoundaryEntry(feedEntry) || - isCompactSummaryEntry(feedEntry) - ) { - entries.push(feedEntry) - indexEntryIntoMaps(feedEntry, toolUseIndex, toolResultIndex) + for (const mapped of mapper.map(raw).entries) { + entries.push(mapped) + indexEntryIntoMaps(mapped, toolUseIndex, toolResultIndex) } } diff --git a/src/renderer/src/workspace/hook/actions/history.ts b/src/renderer/src/workspace/hook/actions/history.ts index 2338948a2..ed7a6b609 100644 --- a/src/renderer/src/workspace/hook/actions/history.ts +++ b/src/renderer/src/workspace/hook/actions/history.ts @@ -1,24 +1,10 @@ -import { DEFAULT_PROVIDER } from '@shared/types/providerKind' +import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import { useCallback } from 'react' import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' import type { SessionId } from '@renderer/workspace/types' import type { Entry } from '@shared/types/transcript' -import { - isCompactBoundaryEntry, - isCompactSummaryEntry, - isConversationEntry, -} from '@shared/types/transcript' -import { - codexHistoryMarker, - codexTurnIdFromRollout, - mapCodexRolloutToFeedEntries, - stampCodexTurnId, -} from '@renderer/workspace/codex/rollout' -import { - claudeHistoryMarker, - extractEmbeddedClaudeProgressEntry, -} from '@renderer/workspace/claude/history' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { deriveAgentWorkContext, ingestWorktreeRawEvent, @@ -27,10 +13,6 @@ import { import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import * as perf from '@renderer/performance/client' -import { - codexEventType, - codexTurnIdFromEventPayload, -} from '@renderer/workspace/codex/eventCursor' // Older history loader — called by Feed's scroll handler when the // user scrolls near the top. @@ -59,7 +41,7 @@ export function useHistoryActions( } const kind = meta.kind ?? DEFAULT_PROVIDER - if ((kind !== 'claude' && kind !== 'codex') || !meta.providerSessionId) { + if (!isAgentProviderKind(kind) || !meta.providerSessionId) { span.end({ skipped: 'unsupported-or-missing-provider-session', kind }) return } @@ -94,14 +76,16 @@ export function useHistoryActions( let workActivity = runtime.workActivity let workContext = runtime.workContext let oldestMarker: string | null = runtime.historyOldestMarker - // Same rolling Codex turn id as the live JSONL ingest path. - // Pagination chunks can start after the original `turn_context` - // marker, so `payload.turn_id` is not just a convenience: it is the - // only durable source in many chunks. Without stamping these older - // response items the live feed and paged history disagree about - // which committed messages belong to the Codex task, and duplicate - // suppression becomes dependent on where the scroll boundary landed. - let codexPaginationTurnId: string | null = null + // Registry-owned mapper (#394 phase 2b). Chunk-scoped: starts + // with a null turn cursor, which for Codex means pagination + // chunks that begin mid-turn rely on `payload.turn_id` — the + // only durable source in many chunks (see the mapper's cursor + // sequencing docs). Without stamping these older response + // items, the live feed and paged history disagree about which + // committed messages belong to the Codex task, and duplicate + // suppression becomes dependent on where the scroll boundary + // landed. + const mapper = getRendererProviderCapabilities(kind).createTranscriptEntryMapper() for (const rawEntry of chunk.entries) { // Older-history pagination walks records that predate the current @@ -117,52 +101,19 @@ export function useHistoryActions( workContext = deriveAgentWorkContext(workActivity) } - if (kind === 'codex') { - const marker = codexHistoryMarker(rawEntry) - const turnContextId = codexTurnIdFromRollout(rawEntry) - if (turnContextId !== null) codexPaginationTurnId = turnContextId - const payloadTurnId = codexTurnIdFromEventPayload(rawEntry) - if (payloadTurnId !== null) codexPaginationTurnId = payloadTurnId - const mappedRaw = mapCodexRolloutToFeedEntries(rawEntry) - const mapped = mappedRaw.map(e => stampCodexTurnId(e, codexPaginationTurnId)) - if (mapped.length > 0 && oldestMarker === runtime.historyOldestMarker) { - oldestMarker = marker - } - for (const entry of mapped) { - const uuid = (entry as { uuid?: string }).uuid - if (uuid && seen.has(uuid)) continue - if (uuid) seen.add(uuid) - prepend.push(entry) - } - const eventType = codexEventType(rawEntry) - if ( - eventType === 'task_complete' || - eventType === 'turn_complete' || - eventType === 'turn_aborted' - ) { - codexPaginationTurnId = null - } - continue - } - - const feedEntry = - extractEmbeddedClaudeProgressEntry(rawEntry) ?? - (rawEntry as Entry) - const marker = claudeHistoryMarker(rawEntry) - if (!( - isConversationEntry(feedEntry) || - isCompactBoundaryEntry(feedEntry) || - isCompactSummaryEntry(feedEntry) - )) { - continue - } - if (marker && oldestMarker === runtime.historyOldestMarker) { + const { entries: mapped, historyMarker: marker } = mapper.map(rawEntry) + // Marker policy (site-owned): only the FIRST kept line of the + // chunk replaces the pagination cursor — `oldestMarker` must + // stay pinned to where the NEXT older page should start. + if (mapped.length > 0 && marker && oldestMarker === runtime.historyOldestMarker) { oldestMarker = marker } - const uuid = (feedEntry as { uuid?: string }).uuid - if (uuid && seen.has(uuid)) continue - if (uuid) seen.add(uuid) - prepend.push(feedEntry) + for (const entry of mapped) { + const uuid = (entry as { uuid?: string }).uuid + if (uuid && seen.has(uuid)) continue + if (uuid) seen.add(uuid) + prepend.push(entry) + } } setRuntimes(prev => { diff --git a/src/renderer/src/workspace/hook/actions/initialHistory.ts b/src/renderer/src/workspace/hook/actions/initialHistory.ts index e9ad5a3fb..bc79d3e0c 100644 --- a/src/renderer/src/workspace/hook/actions/initialHistory.ts +++ b/src/renderer/src/workspace/hook/actions/initialHistory.ts @@ -1,23 +1,8 @@ -import { DEFAULT_PROVIDER } from '@shared/types/providerKind' +import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { Entry } from '@shared/types/transcript' -import { - isCompactBoundaryEntry, - isCompactSummaryEntry, - isConversationEntry, -} from '@shared/types/transcript' import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' import type { SessionId, SessionMeta } from '@renderer/workspace/types' -import { isCodexRolloutEntry } from '@renderer/workspace/codex/entries' -import { - codexHistoryMarker, - codexTurnIdFromRollout, - mapCodexRolloutToFeedEntries, - stampCodexTurnId, -} from '@renderer/workspace/codex/rollout' -import { - claudeHistoryMarker, - extractEmbeddedClaudeProgressEntry, -} from '@renderer/workspace/claude/history' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { indexEntryIntoMaps } from '@renderer/workspace/entries/utils' import { appendFeedDebugLog } from '@renderer/workspace/runtime/feedDebug' import { @@ -33,10 +18,6 @@ import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import * as perf from '@renderer/performance/client' import { hasDurableProviderSession } from '@renderer/workspace/providerSessionIdentity' -import { - codexEventType, - codexTurnIdFromEventPayload, -} from '@renderer/workspace/codex/eventCursor' const INITIAL_HISTORY_CONCURRENCY = 2 let activeInitialHistoryLoads = 0 @@ -103,7 +84,7 @@ export async function loadInitialHistoryForSession({ }): Promise { const meta = metaOverride ?? refs.stateRef.current.sessions[sessionId] const kind = meta?.kind ?? DEFAULT_PROVIDER - if (!meta || (kind !== 'claude' && kind !== 'codex')) return + if (!meta || !isAgentProviderKind(kind)) return if (!hasDurableProviderSession(meta)) { setRuntimes(prev => { @@ -169,7 +150,10 @@ export async function loadInitialHistoryForSession({ let initialOldestMarker: string | null = null let workActivity = current.workActivity let workContext = current.workContext - let codexTurnId: string | null = null + // Registry-owned mapper (#394 phase 2b); chunk-scoped, so the + // Codex turn cursor starts null exactly like the old local + // variable did. + const mapper = getRendererProviderCapabilities(kind).createTranscriptEntryMapper() const toolUseIndex = current.toolUseIndex const toolResultIndex = current.toolResultIndex // Bump `toolIndexVersion` once if this bootstrap load actually populated @@ -187,53 +171,21 @@ export async function loadInitialHistoryForSession({ }) workContext = deriveAgentWorkContext(workActivity) - if (kind === 'codex') { - const marker = codexHistoryMarker(raw) - const turnContextId = codexTurnIdFromRollout(raw) - if (turnContextId !== null) codexTurnId = turnContextId - const payloadTurnId = codexTurnIdFromEventPayload(raw) - if (payloadTurnId !== null) codexTurnId = payloadTurnId - const mappedRaw = mapCodexRolloutToFeedEntries(raw) - const mapped = mappedRaw.map(entry => stampCodexTurnId(entry, codexTurnId)) - if (mapped.length > 0 && !initialOldestMarker) initialOldestMarker = marker - for (const entry of mapped) { - const uuid = (entry as { uuid?: string }).uuid - if (uuid && seen.has(uuid)) continue - if (uuid) seen.add(uuid) - initialEntries.push(entry) - if (indexEntryIntoMaps(entry, toolUseIndex, toolResultIndex)) { - toolIndexChanged = true - } - } - const eventType = codexEventType(raw) - if ( - eventType === 'task_complete' || - eventType === 'turn_complete' || - eventType === 'turn_aborted' - ) { - codexTurnId = null - } - continue - } - - const feedEntry = - extractEmbeddedClaudeProgressEntry(raw) ?? - (raw as Entry) - const marker = claudeHistoryMarker(raw) - if ( - !isConversationEntry(feedEntry) && - !isCompactBoundaryEntry(feedEntry) && - !isCompactSummaryEntry(feedEntry) - ) { - continue + const { entries: mapped, historyMarker: marker } = mapper.map(raw) + // Marker policy (site-owned): the FIRST kept line of the + // bootstrap chunk is the pagination anchor for older-history + // loads. + if (mapped.length > 0 && marker && !initialOldestMarker) { + initialOldestMarker = marker } - if (marker && !initialOldestMarker) initialOldestMarker = marker - const uuid = (feedEntry as { uuid?: string }).uuid - if (uuid && seen.has(uuid)) continue - if (uuid) seen.add(uuid) - initialEntries.push(feedEntry) - if (indexEntryIntoMaps(feedEntry, toolUseIndex, toolResultIndex)) { - toolIndexChanged = true + for (const entry of mapped) { + const uuid = (entry as { uuid?: string }).uuid + if (uuid && seen.has(uuid)) continue + if (uuid) seen.add(uuid) + initialEntries.push(entry) + if (indexEntryIntoMaps(entry, toolUseIndex, toolResultIndex)) { + toolIndexChanged = true + } } } @@ -382,7 +334,7 @@ export function reconcileStuckTranscriptLoads({ const emptyFeed = runtime.entries.length === 0 if (!stuckSpinner && !emptyFeed) continue const meta = sessions[sessionId] - if (!meta || (meta.kind !== 'claude' && meta.kind !== 'codex')) continue + if (!meta || !isAgentProviderKind(meta.kind)) continue // Only durable sessions have a reloadable transcript. Provisional // proxy-header sessions are left to the 'disconnected' path. if (!hasDurableProviderSession(meta)) continue diff --git a/src/renderer/src/workspace/hook/actions/streaming.ts b/src/renderer/src/workspace/hook/actions/streaming.ts index c0b62eaad..f11cc7b84 100644 --- a/src/renderer/src/workspace/hook/actions/streaming.ts +++ b/src/renderer/src/workspace/hook/actions/streaming.ts @@ -10,7 +10,7 @@ import { withDerivedSessionStatus } from '@renderer/workspace/semantic/helpers' import { entryTextContent, } from '@renderer/workspace/entries/utils' -import { isOptimisticCodexUserEntry } from '@renderer/workspace/codex/entries' +import { isOptimisticCodexUserEntry } from '@providers/codex/renderer/transcript/entries' import { isSemanticTurnRunning } from '@renderer/workspace/semantic/helpers' import { buildCommittedAssistantText, diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 3032f980e..9eba0a902 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -1,12 +1,14 @@ -import { DEFAULT_PROVIDER } from '@shared/types/providerKind' +import { + DEFAULT_PROVIDER, + isAgentProviderKind, + type AgentProviderKind, +} from '@shared/types/providerKind' import { useEffect } from 'react' import type { Entry } from '@shared/types/transcript' -import { - isCompactBoundaryEntry, - isCompactSummaryEntry, - isConversationEntry, -} from '@shared/types/transcript' +import { isCompactSummaryEntry } from '@shared/types/transcript' +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' +import type { TranscriptEntryMapper } from '@shared/types/providerConfig' import { emptyRuntime, type SessionRuntime } from '@renderer/workspace/workspaceState' import { appendFeedDebugLog, type FeedDebugInput } from '@renderer/workspace/runtime/feedDebug' import type { SessionId } from '@renderer/workspace/types' @@ -24,21 +26,7 @@ import { extractCodexProviderSessionId, isCodexRolloutEntry, isOptimisticCodexUserEntry, -} from '@renderer/workspace/codex/entries' -import { - codexHistoryMarker, - codexTurnIdFromRollout, - mapCodexRolloutToFeedEntries, - stampCodexTurnId, -} from '@renderer/workspace/codex/rollout' -import { - codexEventType, - codexTurnIdFromEventPayload, -} from '@renderer/workspace/codex/eventCursor' -import { - claudeHistoryMarker, - extractEmbeddedClaudeProgressEntry, -} from '@renderer/workspace/claude/history' +} from '@providers/codex/renderer/transcript/entries' import { entryTextContent, indexEntryIntoMaps, @@ -1330,15 +1318,49 @@ export function useIpcSubscriptions( // signal React gets that a cross-entry pairing moved. let toolIndexChanged = false - // Rolling Codex turn id. `turn_context` was the first source we - // used, but it is too sparse for live rendering: the authoritative - // user message that should replace the optimistic prompt can arrive - // after unrelated tool-result user rows, in a separate burst, with - // only `payload.turn_id` tying it back to the active task. Stamping - // every mapped response item gives reconcileUpstream and semantic - // ownership a stable key instead of guessing from feed position. - let codexCurrentTurnId: string | null = - codexCurrentTurnIdBySession.get(sessionId) ?? null + // Registry-owned transcript mapping (#394 phase 2b). + // + // Routing is by SESSION KIND (state.sessions[id].kind), no + // longer by per-line shape sniffing. WHY: shape sniffing was + // the fourth hand-synced copy of "what does a codex line look + // like," and it silently misclassified a third provider's + // lines as Claude (#394 §4.5). The session kind is the + // authoritative routing key — one transcript file per session, + // one provider format per file. + // + // Fallback: bursts can land before the session's meta exists + // (restore races). In that window `mappingKind` is null and we + // fall back to the old per-line sniff so early codex + // session_meta lines aren't misrouted. Once meta exists, kind + // wins — and a SHADOW comparison counts lines where the sniff + // disagrees with the kind route. Mismatches mean either a + // #290-class wrong-transcript attach (foreign provider's lines + // in this session's stream) or discriminator drift; both are + // exactly what we want visible in feed-debug instead of + // silently rendering as someone else's conversation. + // + // The Codex mapper's rolling turn cursor (formerly the local + // `codexCurrentTurnId`) is seeded from and persisted back to + // `codexCurrentTurnIdBySession` after the loop — the cursor + // must survive across bursts because the authoritative user + // message that replaces an optimistic prompt can arrive in a + // later burst with only `payload.turn_id` tying it back. + const mappingKindRaw = metaForProviderGate?.kind ?? (metaForProviderGate ? DEFAULT_PROVIDER : null) + const mappingKind: AgentProviderKind | null = + mappingKindRaw !== null && isAgentProviderKind(mappingKindRaw) ? mappingKindRaw : null + const mappersByKind = new Map() + const mapperFor = (kind: AgentProviderKind): TranscriptEntryMapper => { + let m = mappersByKind.get(kind) + if (!m) { + m = getRendererProviderCapabilities(kind).createTranscriptEntryMapper( + kind === 'codex' ? codexCurrentTurnIdBySession.get(sessionId) ?? null : null, + ) + mappersByKind.set(kind, m) + } + return m + } + let routeShadowMismatches = 0 + let routeShadowSample: string | null = null for (const { entry: raw } of entries) { if (canIngestWorkContext(raw, hasWorktreeCache)) { @@ -1351,16 +1373,66 @@ export function useIpcSubscriptions( workContext = deriveAgentWorkContext(workActivity) } - // ---- Codex rollout branch ---- - if (isCodexRolloutEntry(raw)) { - const turnContextId = codexTurnIdFromRollout(raw) - if (turnContextId !== null) codexCurrentTurnId = turnContextId - const payloadTurnId = codexTurnIdFromEventPayload(raw) - if (payloadTurnId !== null) codexCurrentTurnId = payloadTurnId - const mappedRaw = mapCodexRolloutToFeedEntries(raw) - const mapped = mappedRaw.map(e => stampCodexTurnId(e, codexCurrentTurnId)) - const marker = mapped.length > 0 ? codexHistoryMarker(raw) : null - if (marker && !oldestMarker) oldestMarker = marker + // ---- Claude queue-operation branch ---- + // Checked BEFORE the mapper on purpose: queue-operation + // entries produce NO feed entries (the claude mapper filters + // them) but carry side effects on queuedMessages / + // awaitingAssistant that only exist at this live site. The + // raw-type check is provider-safe — codex rollout lines + // never carry type 'queue-operation'. See + // claude-code-src/utils/messageQueueManager.ts for the emit + // sites; 'dequeue'/'remove' collapse to "drop head" because + // we don't have identity info to do better. + const entryType = (raw as { type?: string }).type + if (entryType === 'queue-operation') { + const op = raw as { + operation?: 'enqueue' | 'dequeue' | 'remove' + content?: string + timestamp?: string + } + if (op.operation === 'enqueue' && typeof op.content === 'string') { + const ts = op.timestamp ?? String(Date.now()) + const already = queuedMessages.some( + q => q.timestamp === ts && q.content === op.content, + ) + if (!already) { + queuedMessages = [ + ...queuedMessages, + { content: op.content, timestamp: ts }, + ] + } + } else if (op.operation === 'dequeue' || op.operation === 'remove') { + queuedMessages = queuedMessages.slice(1) + } + // Force the streaming flag on whenever the queue has + // items so the streaming card doesn't disappear + // between turns while CC is draining queued work. + if (queuedMessages.length > 0) awaitingAssistant = true + continue + } + + // ---- Unified mapper routing ---- + const shapeSaysCodex = isCodexRolloutEntry(raw) + const routedKind: AgentProviderKind = + mappingKind ?? (shapeSaysCodex ? 'codex' : 'claude') + if (mappingKind !== null && shapeSaysCodex !== (mappingKind === 'codex')) { + routeShadowMismatches += 1 + if (!routeShadowSample) { + routeShadowSample = String((raw as { type?: unknown }).type ?? 'unknown') + } + } + const { entries: mapped, historyMarker: marker } = + mapperFor(routedKind).map(raw) + // Marker policy: first KEPT line of the burst anchors older- + // history pagination. NOTE deliberate unification: the old + // claude live path recorded the marker before its filter + // (any uuid-bearing line, even filtered system lines); the + // bootstrap/older sites only recorded kept lines. Kept-only + // is now uniform — worst case pagination re-reads a few + // filtered lines, which the filter drops again. + if (mapped.length > 0 && marker && !oldestMarker) oldestMarker = marker + + if (routedKind === 'codex') { // Optimistic-user reconciliation. // @@ -1440,88 +1512,31 @@ export function useIpcSubscriptions( reconciledOptimisticText = mappedText } } - - for (const e of mapped) { - const u = entryUuid(e) - if (u) { - if (seen.has(u)) continue - seen.add(u) - } - appended.push(e) - if (indexEntryIntoMaps(e, toolUseIndex, toolResultIndex)) { - toolIndexChanged = true - } - } - const eventType = codexEventType(raw) - if ( - eventType === 'task_complete' || - eventType === 'turn_complete' || - eventType === 'turn_aborted' - ) { - codexCurrentTurnId = null - } - continue } - // ---- Claude queue-operation branch ---- - // queue-operation entries are CC's internal - // message-queue bookkeeping (see - // claude-code-src/utils/messageQueueManager.ts for the - // emit sites). 'enqueue' / 'dequeue' / 'remove' — the - // latter two are collapsed into "drop head" because we - // don't have identity info to do better. Not pushed - // into `entries` (would render as feed noise). - const entryType = (raw as { type?: string }).type - if (entryType === 'queue-operation') { - const op = raw as { - operation?: 'enqueue' | 'dequeue' | 'remove' - content?: string - timestamp?: string + // ---- Shared append path (both providers) ---- + // Dedupe order note: the old claude branch added uuids of + // FILTERED lines to `seen` too (dedupe ran before the + // conversation filter). The mapper filters first, so + // filtered lines never reach dedupe — inconsequential, + // because a re-arriving filtered line is filtered again. + // + // pendingCompaction clearing now applies to BOTH providers' + // mapped compact summaries (the old codex branch never + // cleared it — latent gap; for codex the flag is normally + // null so this is a no-op today, correct if codex ever + // grows a compaction condition). + for (const e of mapped) { + const u = entryUuid(e) + if (u) { + if (seen.has(u)) continue + seen.add(u) } - if (op.operation === 'enqueue' && typeof op.content === 'string') { - const ts = op.timestamp ?? String(Date.now()) - const already = queuedMessages.some( - q => q.timestamp === ts && q.content === op.content, - ) - if (!already) { - queuedMessages = [ - ...queuedMessages, - { content: op.content, timestamp: ts }, - ] - } - } else if (op.operation === 'dequeue' || op.operation === 'remove') { - queuedMessages = queuedMessages.slice(1) + if (isCompactSummaryEntry(e)) pendingCompaction = null + appended.push(e) + if (indexEntryIntoMaps(e, toolUseIndex, toolResultIndex)) { + toolIndexChanged = true } - // Force the streaming flag on whenever the queue has - // items so the streaming card doesn't disappear - // between turns while CC is draining queued work. - if (queuedMessages.length > 0) awaitingAssistant = true - continue - } - - // ---- Claude conversation entry branch ---- - const rawRecord = asRecord(raw) ?? {} - const feedEntry = - extractEmbeddedClaudeProgressEntry(rawRecord) ?? - (raw as Entry) - const marker = claudeHistoryMarker(rawRecord) - if (marker && !oldestMarker) oldestMarker = marker - const uuid = entryUuid(feedEntry) - if (uuid) { - if (seen.has(uuid)) continue - seen.add(uuid) - } - if ( - !isConversationEntry(feedEntry) && - !isCompactBoundaryEntry(feedEntry) && - !isCompactSummaryEntry(feedEntry) - ) { - continue - } - if (isCompactSummaryEntry(feedEntry)) pendingCompaction = null - appended.push(feedEntry) - if (indexEntryIntoMaps(feedEntry, toolUseIndex, toolResultIndex)) { - toolIndexChanged = true } } @@ -1590,10 +1605,18 @@ export function useIpcSubscriptions( window.api.ghostAppend(sessionId, ghost) } - if (codexCurrentTurnId === null) { - codexCurrentTurnIdBySession.delete(sessionId) - } else { - codexCurrentTurnIdBySession.set(sessionId, codexCurrentTurnId) + // Persist the codex mapper's rolling turn cursor across bursts. + // Only when a codex mapper was actually used this burst — a + // claude-kind session leaves the map untouched (equivalent to + // the old read-then-write-back of the same value). + const codexMapper = mappersByKind.get('codex') + if (codexMapper) { + const cursor = codexMapper.getTurnCursor() + if (cursor === null) { + codexCurrentTurnIdBySession.delete(sessionId) + } else { + codexCurrentTurnIdBySession.set(sessionId, cursor) + } } // Bail only when literally nothing changed. Approval, @@ -1704,6 +1727,14 @@ export function useIpcSubscriptions( workContext, workActivity: summarizeWorktreeActivity(workActivity), conditions: current.conditions, + // Shadow route comparison (#394 phase 2b): count of + // lines this burst where the legacy shape sniff + // disagreed with the session-kind route. Nonzero means + // a #290-class foreign-transcript attach or + // discriminator drift — see the routing comment at the + // top of this handler. + routeShadowMismatches, + routeShadowSampleType: routeShadowSample, }, }, ), diff --git a/src/renderer/src/workspace/workspaceStore.ts b/src/renderer/src/workspace/workspaceStore.ts index f11bdcc12..51b1825e5 100644 --- a/src/renderer/src/workspace/workspaceStore.ts +++ b/src/renderer/src/workspace/workspaceStore.ts @@ -27,11 +27,11 @@ export { codexHistoryMarker, codexTurnIdFromRollout, mapCodexRolloutToFeedEntries, -} from '@renderer/workspace/codex/rollout' +} from '@providers/codex/renderer/transcript/rollout' export { claudeHistoryMarker, extractEmbeddedClaudeProgressEntry, -} from '@renderer/workspace/claude/history' +} from '@providers/claude/renderer/transcript/history' export { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' diff --git a/src/shared/types/providerConfig.ts b/src/shared/types/providerConfig.ts index 7f12cde07..774b26f00 100644 --- a/src/shared/types/providerConfig.ts +++ b/src/shared/types/providerConfig.ts @@ -68,6 +68,53 @@ export type TileLeafProps = { */ export type RendererConditionViewRegistry = Record +// --------------------------------------------------------------------------- +// Transcript-entry mapping capability (#394 phase 2b) +// --------------------------------------------------------------------------- + +/** + * Result of mapping ONE raw transcript line (Claude JSONL entry or + * Codex rollout line) into feed entries. + * + * `entries` may be empty (Claude lines filtered by the conversation/ + * compact gate; Codex event_msg lines with no feed representation) or + * carry multiple entries (Codex response_items that fan out). + * + * `historyMarker` is the provider's pagination marker for this line — + * Claude: stable uuid (null for non-conversation lines); Codex: + * synthesized timestamp:id marker (always present). Call sites own the + * policy for WHEN to record it (bootstrap records the first, older- + * pagination replaces conditionally). + */ +export type MappedTranscriptEntry = { + entries: import('@shared/types/transcript.js').Entry[] + historyMarker: string | null +} + +/** + * Provider-owned transcript-line mapper. Created fresh per ingestion + * stream (one per live session, one per history chunk, one per + * preview build). + * + * WHY an interface with a factory instead of a pure function: Codex's + * mapping is stateful — a rolling turn cursor stamps entries with the + * turn they belong to, updated/cleared by lines the mapper itself + * sees. Claude's mapper is stateless and implements the cursor + * methods as no-ops. The live-ingest site persists the cursor across + * bursts via get/setTurnCursor; chunk-scoped sites just discard the + * mapper. + * + * This capability is what killed the quadruplicated per-provider + * mapping loops (live ingest / initial history / older history / + * preview — see #394 §4.5, §9). A third provider implements ONE + * mapper and every ingestion path picks it up from the registry. + */ +export type TranscriptEntryMapper = { + map(raw: Record): MappedTranscriptEntry + getTurnCursor(): string | null + setTurnCursor(id: string | null): void +} + /** * Renderer-side config: only browser-safe imports. * Imported by TileTree, workspaceStore, etc.