Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/providers/claude/renderer/transcript/mapper.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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, unknown>,
): string | null {
return typeof raw.sessionId === 'string' && raw.sessionId.length > 0
? raw.sessionId
: null
}
74 changes: 74 additions & 0 deletions src/providers/codex/renderer/transcript/mapper.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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'
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
isCodexExecWrapperOutput,
parseCodexJson,
stripCodexExecWrapper,
} from '@renderer/workspace/codex/entries'
} from '@providers/codex/renderer/transcript/entries'

// Codex rollout → feed entry mapping.
//
Expand Down
29 changes: 29 additions & 0 deletions src/providers/registry.renderer.capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, unknown>) => string | null
}

const claudeCapabilities: RendererProviderCapabilities = {
Expand All @@ -30,6 +54,8 @@ const claudeCapabilities: RendererProviderCapabilities = {
conditionViews: CLAUDE_VIEWS,
renderToolUse: renderClaudeToolUse,
renderToolResult: renderClaudeToolResult,
createTranscriptEntryMapper: () => createClaudeTranscriptEntryMapper(),
extractProviderSessionId: extractClaudeProviderSessionId,
}

const codexCapabilities: RendererProviderCapabilities = {
Expand All @@ -38,6 +64,9 @@ const codexCapabilities: RendererProviderCapabilities = {
conditionViews: CODEX_VIEWS,
renderToolUse: renderCodexToolUse,
renderToolResult: renderCodexToolResult,
createTranscriptEntryMapper: (initialTurnCursor) =>
createCodexTranscriptEntryMapper(initialTurnCursor ?? null),
extractProviderSessionId: extractCodexProviderSessionId,
}

const rendererProviderCapabilities: Record<AgentProviderKind, RendererProviderCapabilities> = {
Expand Down
37 changes: 13 additions & 24 deletions src/renderer/src/features/session-preview/previewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,28 +63,19 @@ export function buildPreviewModel(
const toolUseIndex = new Map<string, ToolUseBlock>()
const toolResultIndex = new Map<string, ToolResultBlock>()

// 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)
}
}

Expand Down
Loading