Back to Qwen Code

Shared Projection Kernel from Append-only ChatRecords to DaemonTranscriptBlocks

docs/design/2026-07-14-chat-record-daemon-transcript-block-projection.md

0.20.039.4 KB
Original Source

Shared Projection Kernel from Append-only ChatRecords to DaemonTranscriptBlocks

Document Status

  • Status: Implemented
  • Date: 2026-07-14
  • Implementation date: 2026-07-15
  • Scope: core, acp-bridge, cli, sdk-typescript, web-shell
  • Input: append-only unknown records already parsed from JSONL by the caller
  • Output: a DaemonTranscriptBlock projection with diagnostics and completeness information

Conclusion

Implementation result: record preparation, the ACP replay machine, pure live/replay builders, the CLI adapter, provenance-aware compaction, the SDK normalizer/reducer, and the opt-in SDK facade have all landed. The default daemon browser bundle remains within its 151 KiB budget; the minified transcript browser bundle is 67,730 bytes. The separate daemon and daemon/transcript artifacts total 222,335 bytes, while an artifact importing both measures 222,722 bytes. The additional 387 bytes are combined-module wrapper overhead, so callers should treat the transcript subpath as an explicit opt-in cost. Synchronous performance baselines and Web Worker guidance are documented in the SDK README.

Web callers use a separate opt-in SDK subpath:

import {
  projectChatRecordsToDaemonTranscript,
  type ChatRecordTranscriptProjection,
} from "@qwen-code/sdk/daemon/transcript";

const projection = projectChatRecordsToDaemonTranscript(records);
const { blocks, diagnostics, complete, truncated } = projection;

This synchronous function does not start the daemon, Express, or an ACP child process; access the file system, network, DOM, or browser storage; or parse JSONL text. It accepts raw append-only records after JSON.parse and internally performs:

runtime validation
  -> active leaf selection
  -> parentUuid chain reconstruction
  -> same-UUID fragment aggregation
  -> persisted transcript replay
  -> SessionUpdate normalization
  -> DaemonTranscriptBlock projection

The shared implementation is divided into three deep modules with explicit ownership:

packages/core/src/utils/transcript-records.ts
  -> package export @qwen-code/qwen-code-core/transcriptRecords
  -> browser-safe record preparation
  -> active chain, aggregation, gaps, diagnostics

packages/acp-bridge/src/transcript-replay.ts
  -> browser-safe replay machine
  -> shared pure SessionUpdate builders

packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts
  -> SDK adapter
  -> normalizer/reducer/finalize
  -> public projection interface

The CLI's HistoryReplayer and the live MessageEmitter, ToolCallEmitter, and PlanEmitter all reuse the pure update builders from acp-bridge. This prevents drift from merely moving from “CLI versus Web” to “live versus replay”: record interpretation and update construction each have a single implementation.

The SDK adapter wraps the same SessionUpdate values as ID-less DaemonEvent values, reuses the existing normalizeDaemonEvent and transcript reducer, and ultimately returns blocks, diagnostics, complete, and truncated.

Background

The target scenario is read-only rendering in WebShell of persisted JSONL generated by qwen -p, for example:

/root/.qwen/projects/-root--qwen-workspace/chats/<session-id>.jsonl

The browser has already obtained the file contents through a host, file picker, or another trusted read path, and is responsible for parsing the JSONL text into unknown records. The complete path after that is:

parsed append-only records
  -> shared record preparation
  -> shared transcript replay
  -> DaemonTranscriptBlock projection
  -> WebShellTranscript

Callers do not need to understand the parentUuid tree, the active branch after rewind, append fragments with the same UUID, session artifact records, or history gaps. Leaving these persistence semantics to callers would create a shallow module: the interface would appear to be a single function, but using it correctly would require callers to reimplement SessionService knowledge.

This design does not use compactedReplay. That is the bounded in-memory recovery window maintained by the daemon for live sessions; this utility processes persisted records explicitly supplied by the caller. Offline projection has no block-count limit by default, but retains the safety limit for an individual text block and explicitly reports all lossy processing through diagnostics and truncated.

Existing Baseline: How Daemon /load Replays JSONL

The current response-mode /load does not pass JSONL directly to the SDK. The full path is:

SessionService.loadSession
  -> JSONL parse
  -> last non-artifact leaf
  -> buildOrderedUuidChain
  -> same-UUID aggregateRecords
  -> ResumedSessionData.conversation.messages

QwenAgent.loadSession
  -> collectHistoryReplayUpdates
  -> HistoryReplayer
  -> MessageEmitter / ToolCallEmitter / PlanEmitter
  -> SessionUpdate[] in LOAD_REPLAY_META_KEY

acp-bridge restoreSession
  -> extractLoadReplayResponse
  -> BridgeClient.seedSessionUpdates
  -> prepareSessionUpdateFrames
  -> EventBus.seedReplayEvents
  -> compactedReplay + liveJournal

DaemonSessionClient.load
  -> replaySnapshot
  -> normalizeDaemonEvent
  -> reduceDaemonTranscriptEvents
  -> DaemonTranscriptState.blocks

The first half of stream-mode /load is still produced by HistoryReplayer; the updates enter the pending restore EventBus as ACP notifications instead of being included in the load response. Both modes ultimately pass through the same bridge frame preparation, normalizer, and reducer.

The current implementation has three branches that must converge:

  • SessionService and SessionTranscriptReader each have their own aggregateRecords implementation.
  • SessionService chooses the last non-artifact record as the leaf, while SessionTranscriptReader currently chooses the last structurally valid record. Their semantics differ when an artifact happens to be at the end of the file.
  • JSONL replay depends on CLI emitter classes, so the browser cannot reuse it without pulling in Config and the Node runtime.

This design does not create a separate JSONL-to-blocks shortcut. Instead, it extracts the browser-safe record preparation and SessionUpdate construction from the path above, then continues to use the daemon's existing normalization/reduction tail.

Goals

  • Provide a synchronous, in-memory, browser-safe function that projects raw parsed records to a transcript.
  • Consolidate active-chain selection, same-UUID aggregation, and history gaps into one record-preparation module.
  • Make CLI replay, daemon load, and Web offline projection share record interpretation and SessionUpdate construction rules.
  • Make live emitters and the replay machine share pure update builders to preserve live/replay locality.
  • Preserve timestamps, source-record identity, part ordering, tool start/result correlation, pagination state, and EOF dangling cleanup.
  • Produce a deterministic projection for identical input, using deterministic fallbacks for fields that do not depend on the current Config.
  • Treat persisted JSON as untrusted input and distinguish caller errors, recoverable corruption, and forward-compatible unknown values.
  • Emit structured diagnostics for every skip, ambiguity, and truncation; never present a partial projection as complete.

Non-goals

  • Reading files or parsing JSONL text.
  • Simulating EventBus, SSE cursors, Last-Event-ID, or compactedReplay.
  • Inferring unpersisted live-only blocks such as permission, shell, user_shell, or cancellation from records.
  • Pulling core's Node-only reader, provider types, or full runtime into the browser bundle.
  • Guaranteeing unambiguous recovery of concurrent tool calls with the same name when a persisted call ID is absent.
  • Returning the session artifact store; artifacts remain a separate side channel.
  • Moving the entire CLI emitter class hierarchy into a shared leaf; only pure update builders are shared.

Architecture

1. Record Preparation Module

Record preparation is owned by core's persisted-session model. Add a browser-safe leaf:

packages/core/src/utils/transcript-records.ts
  -> @qwen-code/qwen-code-core/transcriptRecords

This module:

  • performs runtime validation on unknown records;
  • selects an explicit leafUuid, or defaults to the last valid non-artifact conversation record;
  • walks from the leaf to the root through parentUuid;
  • stops at a missing parent without joining an earlier island, and produces a HistoryGap;
  • aggregates fragments with the same UUID in active-chain order;
  • uses the field-merging rules currently used by SessionService;
  • identifies cycles, conflicting parentUuid values, corrupted records, and skipped artifact records; and
  • returns new top-level records and part arrays without modifying the input. Validated nested payloads are reused as readonly values rather than deep-cloned without benefit.

Complete arrays and streaming indexes are read differently, so they share the same semantic primitives rather than forcing SessionTranscriptReader to load the entire file into memory:

validateTranscriptRecord
isTranscriptConversationRecord
selectTranscriptLeaf
walkTranscriptUuidChain(lookup)
aggregateTranscriptRecordFragments

prepareTranscriptRecords composes these primitives for raw arrays. SessionService uses the composed function directly. SessionTranscriptReader retains its byte-offset index and paginated reads, but uses the same classifier, lookup-based chain walker, and aggregator. The existing buildOrderedUuidChain is folded into this implementation and must not remain as a second walk.

This both removes the two aggregateRecords implementations and fixes the reader's semantic discrepancy when an artifact is the last record, without sacrificing its streaming index or paginated reads.

This leaf may import only browser-safe types and pure functions. It must not import fs, path, Buffer, the ChatRecordingService class, or provider runtime code.

Core currently has no exports map. The implementation must explicitly preserve exports for the root, transcriptRecords, package.json, and the existing ./dist/* deep imports. Adding a browser leaf must not accidentally close the @qwen-code/qwen-code-core/dist/... paths recorded as compatible by the repository.

2. Transcript Replay Module

SessionUpdate semantics belong to ACP, so the replay machine and pure update builders live in:

packages/acp-bridge/src/transcript-replay.ts
  -> @qwen-code/acp-bridge/transcriptReplay

This module hides:

  • record type/subtype dispatch;
  • message part ordering;
  • text, thought, image, and function-call conversion;
  • tool start/result/dangling state;
  • Todo/plan, diff/content, usage, and provenance;
  • notification, cron, mid-turn message, and slash-command results;
  • source-record metadata; and
  • paginated replay state.

Deleting this module would redistribute complexity across CLI replay, live emitters, and SDK projection, so it passes the deletion test and has sufficient depth.

3. Shared Update Builders

The replay machine does not duplicate the existing update-construction rules in MessageEmitter, ToolCallEmitter, and PlanEmitter. The acp-bridge leaf provides pure builders used only by adapters, such as:

createUserMessageUpdate
createAgentMessageUpdate
createAgentThoughtUpdate
createUsageUpdate
createToolCallStartUpdate
createToolCallResultUpdate
createPlanUpdate

The builders accept only structured parameters and return SessionUpdate. They do not access Config, registries, i18n, or the network.

CLI live emitters:

runtime input
  -> CLI metadata adapter
  -> shared builder
  -> sendUpdate

HistoryReplayer:

prepared ChatRecord
  -> replay machine
  -> shared builder
  -> sendUpdate

SDK offline projection:

prepared ChatRecord
  -> replay machine
  -> shared builder
  -> id-less DaemonEvent
  -> normalizer/reducer

Diff previews, Todo extraction, tool-content transformation, usage-to-plan ordering, and provenance fallbacks must live in the shared builders or their private helpers. Live emitters retain only asynchronous sending and runtime enrichment.

4. SDK Projection Adapter

The SDK facade lives in a separate opt-in entry:

packages/sdk-typescript/src/daemon/ui/chat-record-transcript.ts
packages/sdk-typescript/src/daemon/transcript.ts
@qwen-code/sdk/daemon/transcript

It reuses the daemon UI normalizer and reducer but does not enter the default @qwen-code/sdk/daemon browser bundle. Callers only need to install the SDK and do not depend directly on core or acp-bridge subpaths.

Browser-safe Package Seams

Add two internal leaf exports:

@qwen-code/qwen-code-core/transcriptRecords
@qwen-code/acp-bridge/transcriptReplay

Constraints:

  • No Node built-in imports at runtime.
  • No access to process, Buffer, the DOM, or storage.
  • Prefer type-only imports for provider and ACP packages.
  • The SDK transcript entry inlines the implementation into the published bundle.
  • The SDK's published .d.ts must inline public input/projection types and must not reference an acp-bridge subpath that exists only as a dev dependency.
  • Add Node built-in guards for the core, acp-bridge, and SDK transcript bundles.

Record Preparation Interface

The public SDK facade accepts readonly unknown[]. After internal validation, the core leaf produces:

export interface TranscriptRecordInput {
  readonly uuid: string;
  readonly parentUuid: string | null;
  readonly sessionId: string;
  readonly timestamp?: string;
  readonly type: "user" | "assistant" | "tool_result" | "system";
  readonly subtype?: string;
  readonly message?: {
    readonly role?: string;
    readonly parts?: readonly unknown[];
  };
  readonly usageMetadata?: unknown;
  readonly toolCallResult?: unknown;
  readonly systemPayload?: unknown;
}

export interface TranscriptReplayGapInput {
  readonly childUuid: string;
  readonly missingParentUuid: string;
}

export interface PreparedTranscriptRecords {
  readonly sessionId?: string;
  readonly records: readonly TranscriptRecordInput[];
  readonly gaps: readonly TranscriptReplayGapInput[];
  readonly diagnostics: readonly TranscriptProjectionDiagnostic[];
}

Validation Policy

Fatal caller errors throw TranscriptProjectionInputError directly and return no partial result:

export type TranscriptProjectionInputErrorCode =
  | "invalid_records"
  | "invalid_max_blocks"
  | "leaf_not_found"
  | "mixed_session_ids";

export class TranscriptProjectionInputError extends TypeError {
  readonly code: TranscriptProjectionInputErrorCode;
}
  • records is not an array.
  • options.maxBlocks is not a positive safe integer.
  • An explicit leafUuid does not exist.
  • Two or more structurally valid, different sessionId values are mixed in one projection.

The SDK entry exports this error consistently. Core's internal validation error is mapped at the facade boundary so an internal package class does not leak into the public .d.ts. Other than these cases, a malformed individual record must not make the entire projection throw.

When an individual record or nested payload is malformed, preserve recoverable history wherever possible and emit a diagnostic:

  • Skip non-objects, records with no UUID, invalid parentUuid values, and unknown record types.
  • Retain records with invalid timestamps, but omit serverTimestamp for those records.
  • For conflicting parentUuid values among fragments with a duplicate UUID, keep the first fragment and report the conflict.
  • Stop the chain and report a gap when a parentUuid is missing.
  • Stop the chain and report a cycle when parentUuid values form a cycle.
  • Skip a malformed part of a recognized kind and mark the projection incomplete.
  • Skip unknown forward-compatible subtypes/parts and emit a warning rather than throwing.
  • Skip recognized system subtypes that do not produce transcript content, such as chat_compression, ui_telemetry, file_history_snapshot, and artifact records, according to existing semantics without affecting complete.

Empty input returns empty blocks with complete set to true. Artifact-only input likewise returns an empty transcript, with an informational diagnostic.

An explicit leafUuid must point to a conversation record. Matching only an artifact record is equivalent to the leaf not existing. Artifact records do not enter the UUID chain or participate in duplicate-parent conflict detection.

Diagnostics

export interface TranscriptProjectionDiagnostic {
  readonly code: string;
  readonly severity: "info" | "warning" | "error";
  readonly message: string;
  readonly affectsCompleteness: boolean;
  readonly recordIndex?: number;
  readonly recordId?: string;
  readonly path?: string;
}

Diagnostic messages must not contain unredacted arguments, results, tokens, or credentials. Callers should branch on code; message is only for logging and default presentation.

projection.complete means:

  • no diagnostic has affectsCompleteness set to true;
  • no block or text truncation occurred;
  • replay finalization completed; and
  • no ambiguous tool correlation occurred.

The first version stabilizes at least the following diagnostic codes. Codes are a compatibility contract; messages are not.

codeaffectsCompletenessMeaning
invalid_recordtrueAn entire record was skipped
invalid_timestampfalseContent was retained without historical time
conflicting_parent_uuidtrueSame-UUID fragments have conflicting parents
history_gaptrueThe active chain is missing a parent
parent_cycletrueThe active chain contains a cycle
malformed_parttrueA recognized malformed part was skipped
unknown_record_or_parttrueAn unknown extension may contain visible data
ambiguous_tool_call_correlationtrueA tool result cannot be correlated uniquely
presentation_fallbackfalsePresentation adapter failed; fallback used
transcript_blocks_truncatedtruemaxBlocks removed older blocks
transcript_text_truncatedtrueA text block exceeded the character limit

Artifact-only input may use an informational diagnostic without affecting complete. Adding a code later must not change the affectsCompleteness semantics of an existing code.

Replay Emission Interface

The shared layer emits complete SessionUpdate values and preserves projection provenance:

import type { SessionUpdate } from "@agentclientprotocol/sdk";

export interface TranscriptReplayEmission {
  readonly sourceRecordId: string;
  readonly sourceTimestamp?: string;
  readonly emissionOrdinal: number;
  readonly update: SessionUpdate;
}

An emission corresponds to one record projection, so the outer shape retains a singular sourceRecordId. When written to SessionUpdate, it becomes a single-element sourceRecordIds array for safe merging by subsequent compaction/upsert operations.

export interface TranscriptReplayUsageState {
  readonly promptTokens: number;
  readonly cachedTokens: number;
  readonly candidateTokens: number;
  readonly apiTimeMs: number;
}

export interface PendingTranscriptToolCall {
  readonly callId: string;
  readonly toolName: string;
  readonly sourceRecordId: string;
  readonly sourceTimestamp?: string;
}

export interface TranscriptReplayStateV1 {
  readonly v: 1;
  readonly pendingToolCalls: readonly PendingTranscriptToolCall[];
  readonly cumulativeUsage: TranscriptReplayUsageState;
}

export interface TranscriptReplayMachineOptions {
  readonly initialState?: TranscriptReplayStateV1;
  readonly gaps?: readonly TranscriptReplayGapInput[];
  readonly presentation?: TranscriptReplayPresentationAdapter;
  readonly onDiagnostic?: (
    diagnostic: TranscriptProjectionDiagnostic,
  ) => void;
}

Replay state must be versioned, and snapshot returns a detached copy. Malformed pending entries in initialState are filtered with a diagnostic; invalid or non-finite usage is reset to zero with a diagnostic. An unknown state version is rejected directly to avoid continuing pagination with incorrect state.

For compatibility with transcript cursors issued before deployment, legacy state without v is promoted directly to v1 when it strictly matches the current { pendingToolCalls, cumulativeUsage } shape. An explicit unknown v is still rejected. The legacy branch parses only this one published shape and does not evolve into a second state schema.

Incremental Replay Machine

export interface TranscriptReplayMachine {
  project(
    record: TranscriptRecordInput,
  ): Iterable<TranscriptReplayEmission>;
  finalize(): Iterable<TranscriptReplayEmission>;
  snapshot(): TranscriptReplayStateV1;
}

export function createTranscriptReplayMachine(
  options?: TranscriptReplayMachineOptions,
): TranscriptReplayMachine;

project returns a lazy iterator. The CLI immediately awaits sendUpdate after obtaining each emission and requests the next emission only after the send succeeds. State changes after a generator's yield therefore commit only after the previous send succeeds.

The interface must explicitly document these iteration constraints:

  • Adapters must fully iterate each value returned by project.
  • After an ordinary emission fails to send, stop the current record and all subsequent records.
  • Preserve the current timing for removing a pending tool result.
  • Add a tool start to pending only after it sends successfully.
  • Commit usage before the related plan builder reads cumulative values.
  • finalize is idempotent; its second call returns an empty iterator.
  • The CLI adapter for finalize must catch send errors individually, continue attempting the remaining dangling cleanup, and retain the first cleanup error.
  • Continue using AggregateError when both a replay error and a cleanup error exist.

The SDK adapter has no external asynchronous send failure and can consume each iterator completely.

Tool-call Correlation

Call IDs follow this precedence:

  1. An explicitly persisted ID in functionCall.id, toolCallResult.callId, or functionResponse.id.
  2. If a start has no explicit ID, generate a stable synthetic ID with a reserved prefix that includes the source-record UUID and part index.
  3. If a result has no explicit ID, correlate it only when exactly one pending call has the same name.
  4. When no pending call or multiple pending calls have that name, do not guess. Generate an independent synthetic result ID and emit an ambiguous_tool_call_correlation diagnostic.
  5. Treat uncorrelated starts as dangling tools during finalize.

Synthetic IDs use the qwen-replay-tool: prefix. The machine checks them for collisions with explicit IDs and earlier synthetic IDs, appending a stable occurrence suffix on collision.

A stable fallback guarantees only deterministic identity; it cannot guarantee correct correlation when information is absent.

Source-record Provenance

Record identity must travel through the CLI, daemon, and SDK rather than existing only on the outer emission. A text block usually comes from one record, while a tool block absorbs both start and result records, so wire events and blocks use an ordered, deduplicated array. Replay builders add this to SessionUpdate._meta:

{
  qwenTranscript: {
    sourceRecordIds: ["..."]
  },
  timestamp: 1783958400000
}

Constraints:

  • sourceRecordIds are not EventBus IDs and must not be written to event.id or participate in Last-Event-ID.
  • Convert sourceTimestamp to a finite epoch value in milliseconds at the adapter seam and continue to reuse the existing timestamp field.
  • A history-gap emission uses [gap.childUuid] and the child record's timestamp.
  • SessionUpdate values sent by CLI HistoryReplayer and the SDK offline adapter use identical metadata.
  • Live emitters without persisted-record context do not write qwenTranscript.
  • The normalizer promotes sourceRecordIds from qwenTranscript, then removes the internal transport object from presentation metadata.
  • Add optional readonly sourceRecordIds to DaemonUiEventBase and DaemonTranscriptBlockBase.
  • The reducer merges text/thought/image only when sourceRecordIds are equal and all other merge conditions are satisfied.
  • Tool blocks continue to upsert by toolCallId and union sourceRecordIds in event order. Plan and other upsert blocks use the same stable-union rule.
  • The compaction engine's text-slot key also includes sourceRecordIds, preventing merges across record boundaries.
  • When the compaction engine merges the same toolCallId, it must stably union qwenTranscript.sourceRecordIds; result metadata must not overwrite start provenance.
  • Compare and index sourceRecordIds using structured equality and Map, not an unescaped delimiter join that allows a malicious UUID to cause key collisions.
  • Live events without qwenTranscript retain current compaction behavior.

This preserves identical record segmentation for both daemon /load modes and offline projection, so conformance tests do not need a test-only activeRecordId context.

Adapter Seam for Mutable Presentation Data

export interface TranscriptReplayPresentationAdapter {
  resolveToolMetadata(
    toolName: string,
    args: Readonly<Record<string, unknown>>,
  ): TranscriptReplayToolMetadata;

  formatHistoryGap(gap: TranscriptReplayGapInput): string;
}
  • The CLI adapter uses the current Config/tool registry to resolve title, kind, and locations, and uses CLI i18n to format history gaps.
  • The browser adapter uses deterministic fallbacks: the title is the tool name plus a persisted description argument, the kind is other, locations are empty, and history gaps use fixed SDK copy.

If the adapter throws, the replay machine uses a deterministic fallback and emits a diagnostic instead of allowing presentation enrichment to terminate the entire transcript.

Provenance, Todo/diff/content, usage, and call correlation do not belong at this seam and must be decided by the shared implementation.

CLI Adapter

HistoryReplayer retains its existing calling interface but is reduced to an asynchronous adapter:

prepared records
  -> seed replay state
  -> machine.project(record)
  -> await sendUpdate(emission.update) in order
  -> machine.finalize() when requested
  -> copy machine.snapshot()
  -> clear active replay context

The following behavior remains in the CLI:

  • Config/tool-registry enrichment;
  • localized CLI history-gap text;
  • messageRewriter.interceptUpdate;
  • asynchronous sendUpdate failure handling;
  • combining replay errors and dangling-cleanup errors in an AggregateError; and
  • live-only goals, stop hooks, and other unpersisted events.

Load, paginated transcript, and export paths must use the same record preparation and replay machine so the same JSONL does not produce different SessionUpdate values through different entry points.

SDK Transcript Interface

export interface ChatRecordTranscriptOptions {
  readonly leafUuid?: string;
  readonly maxBlocks?: number;
}

export interface ChatRecordTranscriptProjection {
  readonly blocks: readonly DaemonTranscriptBlock[];
  readonly diagnostics: readonly TranscriptProjectionDiagnostic[];
  readonly complete: boolean;
  readonly truncated: boolean;
}

export function projectChatRecordsToDaemonTranscript(
  records: readonly unknown[],
  options?: ChatRecordTranscriptOptions,
): ChatRecordTranscriptProjection;

When options.maxBlocks is omitted, offline projection does not trim the block count. An explicit value must be a positive safe integer. When trimming occurs:

  • truncated is true;
  • complete is false;
  • diagnostics includes transcript_blocks_truncated; and
  • tool, permission, and parent indexes continue to follow the reducer's safe cleanup rules.

The offline adapter explicitly passes Number.MAX_SAFE_INTEGER as the default. It does not change DEFAULT_MAX_BLOCKS for online createDaemonTranscriptState or put Infinity into reducer state.

The SDK adapter's event path is:

TranscriptReplayEmission
  -> id-less DaemonEvent(type = session_update)
  -> normalizeDaemonEvent
  -> reduceDaemonTranscriptEvents
  -> finalizeOfflineDaemonTranscriptState
  -> ChatRecordTranscriptProjection

Events have no ID because they do not come from EventBus. sourceTimestamp becomes serverTimestamp, and sourceRecordIds remain separate projection provenance.

The offline adapter uses a fixed reducer clock of 0, preventing Date.now from entering observable fields. The same input, options, and presentation adapter must produce a deeply equal projection; serverTimestamp represents the actual historical time.

The new private finalizeOfflineDaemonTranscriptState performs only offline-projection cleanup and is not exported from the default daemon entry:

  • set active assistant/thought blocks' streaming to false;
  • clear active text pointers;
  • do not fabricate a wire event or visible block; and
  • do not modify finalized tool statuses.

An individual text block continues to use the SDK's safety character limit. When character truncation occurs, the reducer diagnostic hook must report transcript_text_truncated and set truncated=true and complete=false; it must not rely only on a visible [truncated] suffix.

To make block/text truncation observable, add an optional onTruncation(detail) to DaemonTranscriptReducerOptions. The detail includes at least the kind, block ID, and sourceRecordIds when present. Ordinary stores do not pass this callback; the offline adapter collects and deduplicates details into projection diagnostics. Do not infer truncation by scanning for [truncated], because user text may contain the same suffix.

Untrusted Identifier Safety

UUIDs, call IDs, and parent IDs in offline input are untrusted strings. Before integration, change these transcript-reducer indexes to Map or null-prototype objects:

  • blockIndexById;
  • toolBlockByCallId;
  • permissionBlockByRequestId;
  • activeAssistantBlockByParent;
  • activeThoughtBlockByParent; and
  • trimmed notification maps.

Tests must cover __proto__, constructor, prototype, toString, and overlong IDs to ensure that they cannot break lookup, parent-child relationships, or trimming cleanup.

Artifacts

The tool-result builder may continue putting persisted artifacts in SessionUpdate metadata for use by the daemon bridge's artifact side channel. DaemonTranscriptBlock has no artifact field, however, and SDK offline projection does not return an artifact store.

Conformance is therefore divided into two layers:

  • SessionUpdate conformance includes artifacts.
  • DaemonTranscriptBlock conformance explicitly ignores the artifact side channel.

If WebShellTranscript needs artifact cards in the future, add a separate artifact projection rather than smuggling artifacts into transcript blocks.

Consistency Contract

Strongly Consistent Behavior

CLI replay and SDK offline projection share the machine, so the following must match:

  • active chain and same-UUID aggregation;
  • record/subtype filtering and update order;
  • supported message text/thought/image shapes and part ordering;
  • tool-call IDs and start/result/dangling state;
  • Todo/plan, diff/content, and raw input/output;
  • usage, task-execution usage, and plan-stat order;
  • notification, cron, mid-turn message, slash-command, and gap insertion positions; and
  • timestamps, sourceRecordIds, and replay diagnostics.

Live emitters and the replay machine share update builders, so fields in SessionUpdate generated for the same semantic event must match.

Explicitly Allowed Adapter Differences

  • Tool title, kind, and locations computed from the CLI's current Config/tool registry.
  • History-gap text in the CLI's current locale.
  • Derived messages added by CLI message rewriting.
  • The artifact side channel.
  • Live-only permission, shell, cancellation, and session events.

If the product requires field-for-field identical tool metadata, replay metadata must be persisted when the tool call is recorded and follow “persisted value first, deterministic fallback.” Historical truth must not be recomputed from the current registry.

Conformance Tests

Tests have six layers:

  1. Core record-preparation golden tests: raw append-only fixtures to active chain, aggregation, gaps, and diagnostics.
  2. acp-bridge builder tests: live/replay inputs assert complete SessionUpdate values.
  3. Replay-machine/compaction tests: order, versioned state, pagination, synthetic IDs, ambiguous correlation, finalization, and retention of sourceRecordIds during text/tool compaction.
  4. CLI adapter regression tests: asynchronous sending, message rewriting, partial failure, dangling cleanup, and AggregateError.
  5. SDK projection tests: ID-less events, sourceRecordIds, normalization, record segmentation, truncation, malicious identifiers, and deterministic blocks.
  6. Cross-package conformance: the same raw fixture passes through real CLI replay and SDK offline projection.

Cross-package paths:

raw records
  -> SDK projectChatRecordsToDaemonTranscript
  -> sdkProjection

raw records
  -> shared record preparation
  -> CLI HistoryReplayer
  -> captured SessionUpdate with qwenTranscript metadata
  -> SDK normalizer/reducer/finalize
  -> cliProjection

Perform deep equality on the canonical projection. The canonicalizer may ignore only explicitly allowed adapter differences; it must not remove sourceRecordIds, timestamps, tool status, diagnostics, or truncation.

Also add daemon integration fixtures that verify response-mode and stream-mode /load retained replay match offline projection when no window truncation occurs. Tests must cross a subsequent turn boundary to cover bridge/compaction retention of qwenTranscript metadata and timestamps.

Integration with WebShellTranscript

import { useMemo } from "react";
import {
  projectChatRecordsToDaemonTranscript,
} from "@qwen-code/sdk/daemon/transcript";
import { WebShellTranscript } from "@qwen-code/web-shell";

function ReadonlyHistory({ records }: { records: readonly unknown[] }) {
  const projection = useMemo(
    () => projectChatRecordsToDaemonTranscript(records),
    [records],
  );

  return (
    <>
      {projection.complete ? null : (
        <TranscriptDiagnostics diagnostics={projection.diagnostics} />
      )}
      <WebShellTranscript blocks={projection.blocks} />
    </>
  );
}

The SDK owns data preparation and projection; WebShell owns only read-only rendering. WebShellTranscript does not add a records prop or start a provider, session, or network connection.

Synchronous Performance Contract

The public facade is a synchronous O(records + parts) projection and scans all input even if an explicit maxBlocks ultimately retains only the tail blocks. maxBlocks limits output memory, not computation.

Before implementation, establish time and peak-memory baselines using small, medium, and large real fixtures, and document the recommended main-thread limit in the SDK documentation. Hosts above that limit should invoke the same browser-safe interface in a Web Worker and pass the projection to the main thread.

The first version does not add a separate async/worker wrapper. Reconsider that adapter after a second real caller appears, avoiding a false seam with only one adapter.

Bundle and Publishing Constraints

The converter does not enter the default @qwen-code/sdk/daemon bundle. Add this package export:

"./daemon/transcript": {
  "types": "./dist/daemon/transcript.d.ts",
  "import": "./dist/daemon/transcript.js",
  "require": "./dist/daemon/transcript.cjs"
}

Build requirements:

  • Separate browser ESM and Node CJS bundles.
  • A separate Node built-in guard.
  • A separate size budget with the baseline commit and measurement command recorded.
  • Public .d.ts files do not leak core/acp-bridge dev dependencies.
  • Measure duplicated code in a sample build that imports both daemon and daemon/transcript.
  • Do not rely on importing the package root or incidental tree shaking for browser safety.

The default daemon budget of 151 KiB does not increase for this feature.

Migration Order

  1. Add the browser-safe transcript record-preparation leaf to core, and make SessionService and SessionTranscriptReader share classification, leaf selection, chain walking, and aggregation.
  2. Add pure SessionUpdate builders to acp-bridge and gradually migrate live emitters to them.
  3. Add the replay machine and golden tests.
  4. Convert HistoryReplayer into a CLI adapter while preserving its existing calling interface and error semantics.
  5. Add qwenTranscript metadata and extend bridge, compaction, normalizer, and reducer handling of sourceRecordIds.
  6. Harden the reducer's untrusted-identifier indexes and truncation diagnostics.
  7. Add the opt-in daemon/transcript facade and separate publish artifacts to the SDK.
  8. Add cross-package conformance and daemon integration fixtures.
  9. Connect WebShell's read-only page to projection.blocks and show diagnostics.

At each step, migrate existing consumers before deleting the old implementation so that no stage has two active-chain, aggregation, or update-builder rule sets at once.

Estimated Code Size

  • Core record preparation and migration of two existing consumers: approximately 180–280 lines of production code.
  • acp-bridge builders and replay machine: approximately 400–550 lines.
  • CLI HistoryReplayer adapter: approximately 60–100 lines.
  • SDK projection facade, identity, and diagnostics glue: approximately 140–220 lines.
  • Reducer safety/truncation support: approximately 60–120 lines.
  • The rest is primarily fixtures, regression tests, and conformance tests.

This is a cross-package core change. A maintainer must confirm the scope under the repository's core triage gate before implementation. Duplicate aggregation or update builders should not be retained merely to reduce the line count.

Lossy Scope

The projection can recover only information present in records. The following is explicitly unrecoverable or potentially lossy:

  • live-only blocks such as permission, shell, user_shell, and prompt_cancelled;
  • the session artifact store;
  • historical truth for the current Config/registry/locale;
  • unsupported binary/audio/fileData;
  • old sidechain sub-agent nesting without parentToolCallId;
  • exact correlation when explicit call IDs are absent and multiple same-name tools are pending;
  • content after the safety character limit of an individual text block;
  • older blocks removed by an explicit caller-supplied maxBlocks; and
  • content skipped because of corrupt input, unknown extensions, or a broken chain.

Every case that affects completeness must emit a diagnostic and set complete=false. Every actual trim must also set truncated=true.