From 54b898463c0a8dd0be22bf54aa94be8848c4492f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:20:18 +0800 Subject: [PATCH] fix(rebase): drop transcript-adapter files resurrected over master's deletion Master's conversation-node engine deleted transcript-adapter.ts and its spec; the rebase's modify/delete conflicts were staged as keep-ours, resurrecting both with a stale dsh-compact import. Remove them; the purge edits they carried died with the files upstream. --- .../src/client/sessions/transcript-adapter.ts | 408 ------------- .../runtime/tests/transcript-adapter.spec.ts | 567 ------------------ 2 files changed, 975 deletions(-) delete mode 100644 packages/client/runtime/src/client/sessions/transcript-adapter.ts delete mode 100644 packages/client/runtime/tests/transcript-adapter.spec.ts diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts deleted file mode 100644 index ce12d3a89c..0000000000 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ /dev/null @@ -1,408 +0,0 @@ -// TranscriptAdapter: the human transcript projected from the raw event window -// in LOG order. The model-visible surface deliberately shadows replaced ranges, -// so it is the wrong source for conversation a reader already saw; this adapter -// keeps every append-origin event at its own log position and contributes one -// marker node per landed compaction checkpoint. Node order is therefore -// seq-monotonic by construction. - -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -// Subpath export (package.json exports "./surface"): all value imports -// go through it — the package root points at lib/index.js (needs a build) which the vite -// browser bundle cannot resolve; surface.ts has no Node dependencies. -import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -// Cordis-free leaf subpath (the dsh-commands/brand shape): the Service Definition's -// declaration of the checkpoint source, reachable as a TYPE from this program. -// The package ROOT is not — it reaches dsh-session's root, whose Context merge -// declares the HOST `sessions: SessionStore` against this program's -// `sessions: ISessions` (TS2717, the one-program-per-side rule in -// docs/development.md). -import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpoint' -import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts' -import { toAssistantBlocks } from './conversation.ts' -import { contextForm, contextProvenance } from './context-provenance.ts' -import { SteeringHistory } from './steering-history.ts' -import type { AssistantStepMetadata } from './assistant-timing.ts' -import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts' - -/** - * The compaction capability's checkpoint plugin, pinned to the Service Definition's declaration - * at COMPILE time: renaming it there fails this annotation (`TS2322`). The - * import stays type-only because a value import would fail the client purity - * gate (`packages/client/tsdown.client.ts`) — cross-plugin value imports are - * forbidden in a browser bundle — while an erased type never reaches it. - */ -const COMPACT_PLUGIN: typeof COMPACT_CHECKPOINT_SOURCE.plugin = 'compact' - -/** In-window tool/call index entry used to materialize result cards. */ -interface CallIndexEntry { - name: string - argsRaw: string - turn: number - step: number - /** Unix epoch ms of the tool/call event. */ - time: number - /** Wire view riding the tool/call (envelope-level; never inside the event). */ - callView: ToolCallView | null -} - -/** One event -> UI node (pure function; the ten-variant ConversationNode union). */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - resultView: ToolResultView | null, - steering: boolean, - stepTimings: ReadonlyMap, -): ConversationNode { - switch (event.type) { - case 'user/message': { - // Injected context (plugin/goal/skill-invocation source) folds to a - // context node, not a user message; only a direct human prompt is a - // user node. A compaction checkpoint never reaches here - // (isCompactCheckpoint routes it away). - if (event.data.source.kind !== 'user') { - return { - kind: 'context', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - provenance: contextProvenance(event.data.source), - form: contextForm(event.data.source), - } - } - if (steering) { - return { - kind: 'steering', messageId: event.data.id, - seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - return { - kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - case 'assistant/message': - return { - kind: 'assistant', seq: event.seq, time: event.time, - turn: event.data.turn, step: event.data.step, - blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time), - } - case 'tool/result': { - const result = event.data.message.content[0] - const callId = String(event.data.message.source.callId) - const call = callIndex.get(callId) - return { - kind: 'tool-result', seq: event.seq, time: event.time, - callId, - call: call ? { name: call.name, argsRaw: call.argsRaw } : null, - callTime: call?.time ?? null, - content: result.content, isError: result.isError === true, - ...(event.data.error !== undefined ? { error: event.data.error } : {}), - meta: event.data.meta, - callView: call?.callView ?? null, - resultView, - subCalls: [], - } - } - /* v8 ignore next 2 -- defensive arm: only the four surface-eligible types - can be append-origin, and each has a case above; reachable only if core - adds an eligible type. */ - default: - return { - kind: 'unknown', seq: event.seq, time: event.time, - type: event.type, data: (event as { data?: unknown }).data, - } - } -} - -/** - * Whether an event is a landed compaction checkpoint — all three conditions, - * matching the terminal's `isCompactCheckpoint`: a `user/message`, carrying the - * compaction seam's checkpoint plugin source, that REPLACED a surface range. A - * plugin-sourced `user/message` that appends is injected context (a - * session-reference card), not a compaction; a replacement `tool/result` is an - * in-place prune and a replacement `assistant/message` a generic rewrite, and - * both mark no boundary in the conversation. - * @param event - the raw window event. - * @returns true when the event compacted a surface range. - */ -function isCompactCheckpoint(event: SessionEvent): boolean { - if (event.type !== 'user/message') return false - const source = event.data.source - return source.kind === 'plugin' && source.plugin === COMPACT_PLUGIN - && isReplacementSurfaceEvent(event) -} - -/** Whether an event contributes a node to the human transcript. */ -function isTranscriptEvent(event: SessionEvent): boolean { - return isAppendSurfaceEvent(event) || isCompactCheckpoint(event) -} - -/** - * Concatenated text of a `compact/summary` payload, or null when it carries no - * usable text. The payload is a `ContentBlock[]` whose union is - * merge-extensible, so a non-text block is skipped rather than discarding the - * text beside it; a payload with no text block at all falls to null through the - * empty check. - */ -function compactSummaryText(event: SessionEvent): string | null { - const summary = (event.data as unknown as { summary?: unknown }).summary - if (!Array.isArray(summary)) return null - let text = '' - for (const block of summary as readonly unknown[]) { - const candidate = block as { type?: unknown; text?: unknown } - if (candidate.type !== 'text' || typeof candidate.text !== 'string') continue - text += candidate.text - } - return text.trim() === '' ? null : text -} - -interface CompactSummaryDetails { - readonly summary: string | null - readonly shadowedItemCount: number | null - readonly shadowedTokenCount: number | null -} - -/** Recover human-facing summary material from one structurally narrowed wire event. */ -function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails { - const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown } - const shadowedSeqs = data.shadowedSeqs - const tokenCount = data.shadowedTokenCount - return { - summary: compactSummaryText(event), - shadowedItemCount: Array.isArray(shadowedSeqs) - && shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0) - ? shadowedSeqs.length - : null, - shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0 - ? tokenCount as number - : null, - } -} - -/** - * One landed checkpoint -> the human-facing compaction marker. The summary text - * comes from the checkpoint's cited `compact/summary` event (`sourceEventSeqs` names the - * `compact/summary` event), never from the framed checkpoint payload, which is - * an instruction envelope written for the model. A window cut that left the - * summary event outside soft-falls to `summary: null` (a non-expandable marker), - * the same posture as a call-less tool result. - */ -function materializeCompaction( - checkpoint: SessionEvent, - eventIndex: ReadonlyMap, -): CompactionSummaryNode { - const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs - let summary: string | null = null - let summaryEventSeq: number | null = null - let shadowedItemCount: number | null = null - let shadowedTokenCount: number | null = null - for (const seq of sources ?? []) { - const candidate = eventIndex.get(seq) - if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue - const details = compactSummaryDetails(candidate) - summary = details.summary - summaryEventSeq = candidate.seq - shadowedItemCount = details.shadowedItemCount - shadowedTokenCount = details.shadowedTokenCount - break - } - return { - kind: 'compaction', - seq: checkpoint.seq, - time: checkpoint.time, - summary, - summaryEventSeq, - shadowedItemCount, - shadowedTokenCount, - } -} - -/** Log-ordered human transcript over a paged raw event window (never consults surface order). */ -export class TranscriptAdapter { - /** Window events by seq, used to find the summary event cited by a checkpoint. */ - private eventIndex = new Map() - /** Transcript nodes in log order; copy-on-write so a published array never mutates. */ - private projected: ConversationNode[] = [] - private callIdx = new Map() - /** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */ - private stepTimings = new Map() - /** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */ - private resultViews = new Map() - /** Durable inbox replay used to distinguish next-step human input from queued prompts. */ - private readonly steeringHistory = new SteeringHistory() - /** - * Command lifecycle nodes by commandId (insertion = run order). The - * `command/run`/`command/done` pair is log-only, so it is not a surface - * event and never joins the transcript projection; this index folds the pair - * (done settles its run's node in place) and nodes() merges the products in - * by seq. Window cuts soft-fall like tool pairs: a done with no in-window - * run still builds a node. - */ - private commandIdx = new Map() - /** Projection revision, bumped only when a transcript node or a command node actually - * changed, keying the nodes() result cache: an unchanged projection returns the previous - * ARRAY reference, not just cached elements — the snapshot's reference-stability contract - * starts here, and a chunk storm bumps nothing at all. */ - private rev = 0 - private nodesResult: { rev: number; value: readonly ConversationNode[] } | null = null - - /** - * Window rebuild (after open/resync/page prepend): re-index the raw window - * and re-project the transcript. - * @param events - the new window contents (seq-ascending). - * @param views - per-event wire views aligned with `events` by index (undefined slots for view-less events). - */ - reset(events: readonly SessionEvent[], views?: readonly (ToolEventView | undefined)[]): void { - this.rev++ - this.eventIndex = new Map() - this.callIdx = new Map() - this.resultViews.clear() - this.commandIdx = new Map() - this.steeringHistory.reset() - const steeringSeqs = new Set() - this.stepTimings = new Map() - for (let i = 0; i < events.length; i++) { - const event = events[i] - /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ - if (event === undefined) continue - this.eventIndex.set(event.seq, event) - this.indexCall(event, views?.[i]) - this.indexCommand(event) - if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq) - indexAssistantStepTiming(this.stepTimings, event) - } - // Indexes first, then project: a tool/result materializes against the - // complete call index, and a checkpoint against the complete event index. - const projected: ConversationNode[] = [] - for (const event of events) { - if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq))) - } - this.projected = projected - } - - /** - * Tail append (live session/event): index the event and, when it belongs to - * the transcript, extend the projection by one copy-on-write node so a - * published array never mutates. An event that changes no node (a chunk - * storm) bumps no revision, so nodes() keeps returning the same array - * reference. - * @param event - the live event (seq = window tail + 1). - * @param view - host-computed tool view paired with the event when it is a tool call/result; indexed for card rendering. - */ - append(event: SessionEvent, view?: ToolEventView): void { - this.eventIndex.set(event.seq, event) - this.indexCall(event, view) - const steering = this.steeringHistory.apply(event) - indexAssistantStepTiming(this.stepTimings, event) - if (this.indexCommand(event)) this.rev++ - if (!isTranscriptEvent(event)) return - this.projected = [...this.projected, this.materialize(event, steering)] - this.rev++ - } - - /** - * The current transcript node array. Same revision -> same array reference - * (memo boundary); node objects are materialized once, so an unchanged node - * keeps its identity across appends. - * @returns transcript nodes in log order, command nodes merged in by seq. - */ - nodes(): readonly ConversationNode[] { - if (this.nodesResult !== null && this.nodesResult.rev === this.rev) return this.nodesResult.value - // Command nodes fold outside the transcript (log-only events); merge by - // seq. Both inputs are seq-ascending (log order and run-index insertion - // order are the same order), so one linear merge keeps flow order. - let nodes = this.projected - if (this.commandIdx.size > 0) { - nodes = [] - const commands = [...this.commandIdx.values()] - let next = 0 - for (const node of this.projected) { - for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) { - nodes.push(cmd) - } - nodes.push(node) - } - for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd) - } - this.nodesResult = { rev: this.rev, value: nodes } - return nodes - } - - /** Materialize one transcript event against the complete current indexes. */ - private materialize(event: SessionEvent, steering: boolean): ConversationNode { - return isCompactCheckpoint(event) - ? materializeCompaction(event, this.eventIndex) - : materializeNode( - event, - this.callIdx, - this.resultViews.get(event.seq) ?? null, - steering, - this.stepTimings, - ) - } - - /** - * Fold one command lifecycle event into its node (run mints, done settles in - * place; done-only soft-falls). - * @returns whether the command index changed, so callers can bump the revision. - */ - private indexCommand(event: SessionEvent): boolean { - // Log-only plugin events: the host-side dsh-commands declaration cannot - // enter the client program, so this wire consumer narrows structurally - // (the same posture as tool/code-dispatch in session.ts). - if ((event.type as string) === 'command/run') { - const data = event.data as unknown as { commandId: CommandId; name: string; args?: string } - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: data.name, args: data.args ?? null, outcome: null, - }) - return true - } - if ((event.type as string) !== 'command/done') return false - const data = event.data as unknown as { - commandId: CommandId - kind: 'success' | 'error' - text?: string - sourceEventSeq?: number - } - const run = this.commandIdx.get(data.commandId) - const sourceEventSeq = data.kind === 'success' - && Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0 - ? data.sourceEventSeq as number - : undefined - const outcome = { - kind: data.kind, - ...data.text === undefined ? {} : { text: data.text }, - ...sourceEventSeq === undefined ? {} : { sourceEventSeq }, - } - if (run === undefined) { - // Cross-window cut: the run page fell out of the window — build the - // node from the done alone (same soft-fall as a call-less tool result). - this.commandIdx.set(data.commandId, { - kind: 'command', seq: event.seq, time: event.time, - commandId: data.commandId, name: null, args: null, outcome, - }) - return true - } - // Settle in place: a fresh node object (published references stay immutable). - this.commandIdx.set(data.commandId, { ...run, outcome }) - return true - } - - private indexCall(event: SessionEvent, view?: ToolEventView): void { - if (event.type === 'tool/result') { - if (view?.for === 'result') this.resultViews.set(event.seq, view.view) - return - } - if (event.type !== 'tool/call') return - this.callIdx.set(String(event.data.callId), { - name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, - time: event.time, - callView: view?.for === 'call' ? view.view : null, - }) - // No backfill into already-materialized tool-result nodes for this callId - // (window order puts the call before its result; cannot happen on the normal path). - } -} diff --git a/packages/client/runtime/tests/transcript-adapter.spec.ts b/packages/client/runtime/tests/transcript-adapter.spec.ts deleted file mode 100644 index 893635dcc7..0000000000 --- a/packages/client/runtime/tests/transcript-adapter.spec.ts +++ /dev/null @@ -1,567 +0,0 @@ -/** - * TranscriptAdapter over the raw append-only window: log-ordered projection of - * append-origin events, one marker per landed compaction, replacement copies - * hidden, command-lifecycle folding, node/array identity, call pairing, and - * host-provided wire views. - */ - -import { createUserMessage, CallId, createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' -import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { TranscriptAdapter } from '../src/client/sessions/transcript-adapter.ts' -import { ev, plainTurn } from './event-script.ts' - -const at = (seq: number, e: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...e }) as unknown as SessionEvent - -/** A `compact/summary` event (log-only, no surfaceOp). */ -function compactSummary(seq: number, summary: unknown = [{ type: 'text', text: '# 摘要\n\n保留事实' }]): SessionEvent { - return at(seq, { - type: 'compact/summary', - data: { - summary, - shadowedRange: { start: 1, end: 3 }, - shadowedSeqs: [1, 3], - shadowedTokenCount: 100, - provider: 'fake', - model: 'compact-1', - }, - }) -} - -/** The replacement user message a compaction backend lands (the checkpoint). */ -function checkpoint( - seq: number, - summarySeq: number, - { start = 1, end = 3, sourceEventSeqs = [summarySeq, start, end] }: { - start?: number - end?: number - sourceEventSeqs?: number[] - } = {}, -): SessionEvent { - return at(seq, { - type: 'user/message', - surfaceOp: { op: 'replace', start, end }, - sourceEventSeqs, - data: createUserMessage({ - content: [{ type: 'text', text: 'model only' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - }) -} - -describe('TranscriptAdapter', () => { - it('projects a window starting past seq 0 at its own log positions', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(100, 5, '偏移问', '偏移答')) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 101], ['assistant', 103]]) - }) - - it('appends incrementally keeping old node references (materialize-once identity)', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const first = adapter.nodes() - adapter.append(ev.user(6, '追加')) - const second = adapter.nodes() - expect(second).toHaveLength(3) - expect(second[0]).toBe(first[0]) - expect(second[1]).toBe(first[1]) - expect(second).not.toBe(first) // a real change swaps the array - }) - - it('keeps the array reference across a chunk storm and swaps it when a node lands', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - const settled = adapter.nodes() - adapter.append(ev.chunkStart(6, 1)) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.chunkText(7, 1, '流式')) - expect(adapter.nodes()).toBe(settled) - adapter.append(ev.assistant(8, 1, '流式完成')) - const finalized = adapter.nodes() - expect(finalized).not.toBe(settled) - expect(finalized.at(-1)).toMatchObject({ kind: 'assistant', seq: 8 }) - }) - - it('materializes every append-origin variant with field mapping', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: '插话' }], - source: { kind: 'user' }, - }) - adapter.reset([ - ev.user(0, '用户'), - ev.assistant(1, 0, '助手'), - at(2, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(4, { type: 'user/message', surfaceOp: 'append', data: steering }), - at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' }, - }) }), - ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'), - ev.toolResult(7, 0, 'c1', '结果'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result']) - expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id }) - expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({ - callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false, - }) - }) - - it('identifies steering on the live append path', () => { - const adapter = new TranscriptAdapter() - const steering = createUserMessage({ - content: [{ type: 'text', text: 'live steer' }], - source: { kind: 'user' }, - }) - adapter.reset([]) - adapter.append(at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } })) - adapter.append(at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } })) - adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering })) - expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }]) - }) - - it('does not mark queued, canceled, or non-user next-step messages as steering', () => { - const adapter = new TranscriptAdapter() - const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) - const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } }) - const context = createUserMessage({ - content: [{ type: 'text', text: 'context' }], - source: { kind: 'plugin', plugin: 'test' }, - }) - adapter.reset([ - at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, inserted: [queued], - } }), - at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-turn', start: 0, removedCount: 1, inserted: [], - } }), - at(2, { type: 'user/message', surfaceOp: 'append', data: queued }), - at(3, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [canceled], - } }), - at(4, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled', - } }), - at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }), - at(6, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [context], - } }), - at(7, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(8, { type: 'user/message', surfaceOp: 'append', data: context }), - ]) - expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context']) - }) - - it('materializes a skill-invocation injection as a named instructions context', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '/hidden-demo check the fixture' }], - source: { kind: 'user' }, - }) }), - at(1, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: 'body' }], - source: { kind: 'skill-invocation', name: 'hidden-demo', form: 'instructions' } as never, - }) }), - ]) - const nodes = adapter.nodes() - // The gesture stays a user bubble; the injected body folds to a context - // row named after the skill, presented as instructions. - expect(nodes.map(node => node.kind)).toEqual(['user', 'context']) - expect(nodes[1]).toMatchObject({ - provenance: { role: 'inject', label: 'hidden-demo' }, - form: 'instructions', - }) - }) - - it('skips events core does not call surface-eligible, marker or not', () => { - // The transcript is the append-origin surface, so log-only events (a chunk, - // a turn boundary, a `compact/*` record) and a future type core - // has not admitted contribute no node. - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 1), - at(1, { type: 'notice/message', surfaceOp: 'append', data: { note: 1 } }), - compactSummary(2), - ev.user(3, '唯一的一条'), - ev.turnEnd(4, 1), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 3]]) - }) - - describe('compaction markers', () => { - it('keeps the original messages and full tool output, hiding replacement copies', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '原始问题'), - ev.assistant(1, 0, '原始回答'), - ev.toolCall(4, 0, 'c1', 'echo', '{}'), - ev.toolResult(5, 0, 'c1', '完整工具输出'), - // A pruned tool/result copy: rewrites one node for the model, marks nothing. - at(6, { type: 'tool/result', surfaceOp: { op: 'replace', start: 5, end: 5 }, sourceEventSeqs: [5], data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [{ type: 'text', text: '已裁剪' }], isError: false }), - } }), - compactSummary(7), - checkpoint(8, 7, { start: 1, end: 5, sourceEventSeqs: [7, 1, 5] }), - // A regenerated assistant/message: also a silent model-only rewrite. - at(9, { type: 'assistant/message', surfaceOp: { op: 'replace', start: 8, end: 8 }, sourceEventSeqs: [8], data: { - turn: 0, step: 0, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: '通用 replacement 副本' }], - source: { kind: 'model', ...{ provider: 'x', model: 'copy' } }, - }), - } }), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([ - ['user', 0], ['assistant', 1], ['tool-result', 5], ['compaction', 8], - ]) - expect(nodes[2]).toMatchObject({ kind: 'tool-result', content: [{ type: 'text', text: '完整工具输出' }] }) - expect(nodes[3]).toMatchObject({ kind: 'compaction', summary: '# 摘要\n\n保留事实' }) - }) - - it('adds one marker per landed compaction, in log order', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, 'a'), - compactSummary(1, [{ type: 'text', text: 'first' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ev.user(3, 'b'), - compactSummary(4, [{ type: 'text', text: 'second' }]), - checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }), - ]) - expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - { - kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second', - summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('renders the marker when the shadowed range is outside the window and logs nothing', () => { - // Pagination spends no quota on replacement copies, so a page can carry - // a checkpoint whose surfaceOp.start lies below the window head. A - // surface fold would throw on the missing range and degrade with a - // console error; a log-ordered - // projection has no range to resolve. - const adapter = new TranscriptAdapter() - const noise = { error: console.error, warn: console.warn } - const logged: unknown[] = [] - console.error = (...args: unknown[]) => logged.push(args) - console.warn = (...args: unknown[]) => logged.push(args) - try { - adapter.reset([ - compactSummary(80, [{ type: 'text', text: '窗外范围' }]), - checkpoint(81, 80, { start: 3, end: 40, sourceEventSeqs: [80, 3, 40] }), - ev.user(82, '压缩后的新问题'), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['compaction', 81], ['user', 82]]) - expect(adapter.nodes()[0]).toMatchObject({ summary: '窗外范围' }) - } finally { - console.error = noise.error - console.warn = noise.warn - } - expect(logged).toEqual([]) - }) - - it('treats an APPENDING plugin-sourced user/message as injected context, not a compaction', () => { - // A session-reference card carries the same plugin source shape; only the - // replacement marker makes an event a checkpoint. - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({ - content: [{ type: 'text', text: '注入的上下文' }], - source: { kind: 'plugin', plugin: 'compact', form: 'instructions' }, - }) }), - ]) - expect(adapter.nodes()).toMatchObject([{ - kind: 'context', - seq: 0, - provenance: { role: 'inject', label: 'compact' }, - form: 'instructions', - }]) - }) - - it('ignores a foreign plugin s replacement user/message', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '保留'), - at(1, { type: 'user/message', surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0], data: createUserMessage({ - content: [{ type: 'text', text: '别的插件重写' }], - source: { kind: 'plugin', plugin: 'not-compact' }, - }) }), - ]) - expect(adapter.nodes().map(n => [n.kind, n.seq])).toEqual([['user', 0]]) - }) - - it.each([ - ['absent summary event', undefined], - ['text-less summary blocks', compactSummary(1, [{ type: 'image', data: 'nope' }])], - ['a whitespace-only summary', compactSummary(1, [{ type: 'text', text: ' ' }])], - ['an empty summary array', compactSummary(1, [])], - ['a non-array summary', compactSummary(1, 'plain string')], - ])('degrades %s to a non-expandable marker', (_label, summary) => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ...(summary === undefined ? [] : [summary]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toMatchObject([ - { kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }, - ]) - }) - - it('keeps the text of a mixed-block summary, skipping the blocks it cannot render', () => { - // ContentBlock is merge-extensible and the payload type is ContentBlock[], - // so a non-text block must not discard recoverable text beside it. - const adapter = new TranscriptAdapter() - adapter.reset([ - compactSummary(1, [{ type: 'text', text: '可用摘要' }, { type: 'image', data: 'nope' }]), - checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }), - ]) - expect(adapter.nodes()).toEqual([ - { - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要', - summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100, - }, - ]) - }) - - it('leaves the summary null when the checkpoint cites no source events', () => { - const adapter = new TranscriptAdapter() - adapter.reset([at(2, { - type: 'user/message', - surfaceOp: { op: 'replace', start: 0, end: 0 }, - data: createUserMessage({ - content: [{ type: 'text', text: 'x' }], - source: { kind: 'plugin', plugin: 'compact' }, - }), - })]) - expect(adapter.nodes()).toEqual([{ - kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null, - summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null, - }]) - }) - - it('skips a cited non-summary seq before reaching the summary event', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '被压缩的问题'), - at(1, { type: 'compact/start', data: { turn: 0 } }), - compactSummary(2, [{ type: 'text', text: '第三个来源才是摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [1, 2, 0] }), - ]) - expect(adapter.nodes().at(-1)).toMatchObject({ kind: 'compaction', summary: '第三个来源才是摘要' }) - }) - - it('resolves the summary once an older page supplies the cited summary event', () => { - const adapter = new TranscriptAdapter() - const landed = checkpoint(8, 7, { start: 0, end: 0, sourceEventSeqs: [7, 0] }) - adapter.reset([landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: null }) - adapter.reset([compactSummary(7, [{ type: 'text', text: '分页补齐的摘要' }]), landed]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'compaction', summary: '分页补齐的摘要' }) - }) - - it('creates the marker on the live append path', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) - adapter.append(compactSummary(6, [{ type: 'text', text: '直播摘要' }])) - adapter.append(checkpoint(7, 6, { start: 1, end: 3, sourceEventSeqs: [6, 1, 3] })) - const nodes = adapter.nodes() - // The compacted history is still there; the marker is one more row after it. - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 1], ['assistant', 3], ['compaction', 7]]) - expect(nodes.at(-1)).toMatchObject({ kind: 'compaction', seq: 7, summary: '直播摘要' }) - }) - }) - - it('returns call:null for a tool-result whose call fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', callId: 'outside-call', call: null }) - }) - - it('materializes a tool-result error field when present', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - at(0, { type: 'tool/result', surfaceOp: 'append', data: { - turn: 0, step: 0, - message: createToolResultMessage({ callId: CallId('c1'), content: [], isError: true }), - error: { name: 'Boom', code: 'boom' }, - } }), - ]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'tool-result', isError: true, error: { code: 'boom' } }) - }) - - it('attaches wire views to the materialized result node', () => { - const adapter = new TranscriptAdapter() - const callView = { for: 'call' as const, view: { card: 'terminal' as const, command: 'ls' } } - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '完成' } } - adapter.reset([ - ev.toolCall(0, 1, 'c1', 'bash', '{"cmd":"ls"}'), - ev.toolResult(1, 1, 'c1', 'listing'), - ], [callView, resultView] as never) - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { card: 'terminal' }, resultView: { card: 'generic', title: '完成' }, - }) - }) - - it('attaches views on the live append path and defaults to null without views', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'a', 'b')) // no views argument - adapter.append(ev.toolCall(6, 1, 'c2', 'echo', '{}'), { for: 'call', view: { card: 'generic', title: '回声' } } as never) - adapter.append(ev.toolResult(7, 1, 'c2', 'ok')) // no view on the result - expect(adapter.nodes().find(n => n.kind === 'tool-result')).toMatchObject({ - callView: { title: '回声' }, resultView: null, - }) - }) - - it('leaves callView null when the paired call fell outside the window (cross-page break)', () => { - const adapter = new TranscriptAdapter() - const resultView = { for: 'result' as const, view: { card: 'generic' as const, title: '孤儿' } } - adapter.reset([ev.toolResult(50, 3, 'outside', '窗外配对')], [resultView] as never) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'tool-result', call: null, callView: null, resultView: { title: '孤儿' }, - }) - }) - - describe('command lifecycle nodes', () => { - it('folds a run/done pair into one settled node merged into flow order by seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '先说话'), - ev.commandRun(1, 'cmd-1', 'plan'), - ev.commandDone(2, 'cmd-1', 'success', '已进入 plan mode'), - ev.assistant(3, 0, '然后回答'), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['assistant', 3]]) - expect(nodes[1]).toMatchObject({ - kind: 'command', commandId: 'cmd-1', name: 'plan', args: '', - outcome: { kind: 'success', text: '已进入 plan mode' }, - }) - }) - - it('renders a run with no done as still executing (outcome null)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRun(0, 'cmd-2', 'goal', ' ship it')]) - expect(adapter.nodes()[0]).toMatchObject({ kind: 'command', name: 'goal', args: ' ship it', outcome: null }) - }) - - it('represents command input omitted by the host as null', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandRunWithoutInput(0, 'cmd-private', 'feedback')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', name: 'feedback', args: null, outcome: null, - }) - }) - - it('soft-falls a done-only window into a node built from the done (cross-window cut)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.commandDone(80, 'cmd-3', 'error', '失败了')]) - expect(adapter.nodes()[0]).toMatchObject({ - kind: 'command', seq: 80, commandId: 'cmd-3', name: null, args: null, - outcome: { kind: 'error', text: '失败了' }, - }) - }) - - it('settles a live-appended done in place, keeping the node at the run seq', () => { - const adapter = new TranscriptAdapter() - adapter.reset(plainTurn(0, 0, 'q', 'a')) - adapter.append(ev.commandRun(6, 'cmd-4', 'clear')) - const running = adapter.nodes().find(n => n.kind === 'command') - expect(running).toMatchObject({ outcome: null }) - adapter.append(ev.commandDone(7, 'cmd-4')) - const settled = adapter.nodes().find(n => n.kind === 'command') - expect(settled).toMatchObject({ seq: 6, outcome: { kind: 'success' } }) - // Settlement replaced the node object rather than mutating the published one. - expect(settled).not.toBe(running) - }) - - it('tails command nodes whose seq is past every transcript node', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问'), ev.commandRun(1, 'cmd-tail', 'plan')]) - expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command']) - }) - - it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.user(0, '压缩前的问题'), - ev.commandRun(1, 'cmd-compact', 'compact'), - compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]), - checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }), - ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2), - ]) - const nodes = adapter.nodes() - expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]]) - expect(nodes[1]).toMatchObject({ - name: 'compact', - outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 }, - }) - expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 }) - }) - }) - - describe('assistant timing', () => { - const base = 1_700_000_000_000 - - it('derives step timing across a window rebuild (start + first token + completion)', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ - ev.turnStart(0, 0), - ev.user(1, '问'), - ev.stepStart(2, 0), - ev.chunkStart(3, 0), - ev.chunkText(4, 0, '答'), - ev.chunkText(5, 0, '案'), - ev.assistant(6, 0, '答案'), - ev.turnEnd(7, 0), - ]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 }, - }) - }) - - it('derives the same timing on the live append path, first token winning once', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.user(0, '问')]) - adapter.append(ev.stepStart(1, 0)) - adapter.append(ev.chunkText(2, 0, '首')) - adapter.append(ev.chunkText(3, 0, '次')) - adapter.append(ev.assistant(4, 0, '首次')) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 }, - }) - }) - - it('soft-falls to null boundaries when the step opening fell outside the window', () => { - const adapter = new TranscriptAdapter() - adapter.reset([ev.assistant(100, 0, '被切窗的答案')]) - const assistant = adapter.nodes().find(n => n.kind === 'assistant') - expect(assistant).toMatchObject({ - timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 }, - }) - }) - }) -})