From 3e79f7106e6017142a21037028f15781a39fce10 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:37:59 +0800 Subject: [PATCH 01/17] feat(client-runtime): add target-owned conversation snapshots --- .../src/client/contract/conversation.ts | 21 +++++-- .../src/client/conversation/event-registry.ts | 13 ++++- packages/client/runtime/src/client/index.ts | 37 ++---------- .../client/sessions/conversation-assembler.ts | 57 +++++++++++-------- .../src/client/sessions/conversation.ts | 9 ++- .../runtime/src/client/sessions/session.ts | 1 + .../client/runtime/tests/client-apply.spec.ts | 1 + .../tests/conversation-assembler.spec.ts | 23 ++++++-- .../tests/conversation-registry.spec.ts | 1 + packages/client/runtime/tests/session.spec.ts | 5 +- packages/client/test-runtime/src/fixtures.ts | 5 +- .../client/conversation-nodes/assistant.ts | 4 +- .../src/client/conversation-nodes/command.ts | 4 +- .../client/conversation-nodes/compaction.ts | 4 +- .../src/client/conversation-nodes/fallback.ts | 3 +- .../src/client/conversation-nodes/inbox.ts | 1 - .../src/client/conversation-nodes/message.ts | 5 +- .../src/client/conversation-nodes/retry.ts | 5 +- .../src/client/conversation-nodes/tool.ts | 4 +- .../client/conversation-nodes/turn-error.ts | 4 +- .../client/conversation-nodes/turn-tail.ts | 4 +- .../ui-conversation/tests/chat-stats.spec.tsx | 3 +- .../ui-conversation/tests/chat-view.spec.tsx | 6 +- .../tests/gate-branch-tails.spec.tsx | 6 +- .../ui-conversation/tests/input-bar.spec.tsx | 6 +- .../tests/input-matrix.spec.tsx | 6 +- .../tests/input-scenarios.spec.tsx | 6 +- .../ui-conversation/tests/queue-dock.spec.tsx | 6 +- .../ui-conversation/tests/skeleton.spec.tsx | 6 +- .../src/client/turn-deliverables.ts | 1 + .../tests/produced-files.spec.tsx | 2 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 6 +- .../client/ui-tool/tests/diff-card.spec.tsx | 7 ++- .../client/ui-tool/tests/read-card.spec.tsx | 7 ++- .../client/ui-tool/tests/search-card.spec.tsx | 7 ++- .../ui-tool/tests/terminal-card.spec.tsx | 7 ++- .../client/ui-tool/tests/web-card.spec.tsx | 7 ++- 37 files changed, 183 insertions(+), 117 deletions(-) diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 9507046b33..7119980839 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -110,6 +110,17 @@ export interface ConversationViewNode { readonly data: unknown } +/** Merge-extensible immutable snapshots published by registered view targets. */ +export interface ConversationViewSnapshotMap {} + +/** Stable reader over the latest snapshot of every registered view target. */ +export interface ConversationViewSnapshotStore { + /** @param target - registered view target. @returns its current snapshot. */ + get( + target: Target, + ): ConversationViewSnapshotMap[Target] | undefined +} + /** Final Chat render unit produced directly by a business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { readonly target: 'chat' @@ -159,6 +170,8 @@ export type ConversationLocationDataScope = 'step' | 'turn' /** One independently registered business Event-to-Node state machine. */ export interface ConversationNodeDefinition { readonly kind: string + /** Sole view target owned by this Definition; omitted for state-only Contexts. */ + readonly target?: string /** * Extract this Definition's stable business identity from one event. * @param event - raw Session event; no Context or history access is available. @@ -207,15 +220,11 @@ export interface ConversationNodeDefinition { scope: ConversationLocationDataScope, ): ConversationLocationData | null /** - * Materialize one final Node for a registered view target. + * Materialize one final Node for this Definition's declared view target. * @param context - latest complete Context. - * @param target - registered view target such as `chat`. * @returns final Node, or null when this Context is not currently visible. */ - buildViewNode( - context: ConversationNodeContext, - target: string, - ): ConversationViewNode | null + buildViewNode?(context: ConversationNodeContext): ConversationViewNode | null } /** Reference-stable Turn/Step facts published beside view Nodes. */ diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts index 381fff81b5..d9eabda538 100644 --- a/packages/client/runtime/src/client/conversation/event-registry.ts +++ b/packages/client/runtime/src/client/conversation/event-registry.ts @@ -17,6 +17,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry void { + assertDefinitionTarget(definition) return this.registerDefinition( definition.kind, definition, @@ -31,6 +32,9 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry void { + assertDefinitionTarget(definition) + const target = definition.target + if (target === undefined) throw new Error('conversation fallback Definition must declare a target') if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered') const owner = this.ctx const dispose = owner.effect(() => { @@ -52,5 +56,12 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry sessions.scopeOf(candidate), }) - const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( () => workspaces.startInitialSelection(), @@ -244,11 +237,6 @@ export function apply(ctx: Context): void { const loop = connection.start({ onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) - try { - sessionHistory.handleMuxEnvelope(envelope) - } catch (error) { - console.error('[web-runtime] history frame routing failed:', error) - } }, onHostEnvelope: (envelope) => { sessions.handleHostEnvelope(envelope) @@ -264,21 +252,11 @@ export function apply(ctx: Context): void { else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns) else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref) else if (frame.type === 'host/models-changed') ctx.emit('models/changed') - try { - sessionHistory.handleHostEnvelope(envelope) - } catch (error) { - console.error('[web-runtime] history host-frame routing failed:', error) - } }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() ctx.emit('connection/reset') - try { - sessionHistory.handleConnected() - } catch (error) { - console.error('[web-runtime] history reconnect failed:', error) - } }, onStateChange: (state) => { // Generation death fires before any next-generation frame can arrive @@ -286,11 +264,6 @@ export function apply(ctx: Context): void { // the only safe moment to drop generation-scoped interaction state. if (state === 'reconnecting') { sessions.handleDisconnected() - try { - sessionHistory.handleDisconnected() - } catch (error) { - console.error('[web-runtime] history disconnect failed:', error) - } } }, }) diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index bdd89f56a4..ee8e6b0eae 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -2,7 +2,8 @@ import type { ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, - ConversationViewDefinition, ConversationViewNode, + ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, + ConversationViewSnapshotStore, } from '../contract/conversation.ts' import { conversationContextKey } from '../contract/conversation.ts' import { @@ -133,7 +134,7 @@ export interface ConversationViewDefinitions { * Session-owned incremental engine that assembles business Contexts from a * contiguous Event window and materializes registered view snapshots. */ -export class ConversationNodeAssembler { +export class ConversationNodeAssembler implements ConversationViewSnapshotStore { private readonly contexts = new Map() private readonly contextsByKind = new Map() private readonly contextsBySeq = new Map>() @@ -266,11 +267,11 @@ export class ConversationNodeAssembler { const allByTarget = new Map() for (const target of this.views.keys()) allByTarget.set(target, []) for (const context of this.contexts.values()) { - for (const target of this.views.keys()) { - const node = this.buildNode(context, target) - context.current.set(target, node) - if (node !== null) allByTarget.get(target)?.push(node) - } + const target = context.definition.target + if (target === undefined || !this.views.has(target)) continue + const node = this.buildNode(context, target) + context.current.set(target, node) + if (node !== null) allByTarget.get(target)?.push(node) } for (const view of this.views.values()) { view.snapshot = view.builder.replace({ @@ -288,17 +289,17 @@ export class ConversationNodeAssembler { for (const target of this.views.keys()) upsertsByTarget.set(target, []) if (this.applyDirtyLocationData()) this.timelineDirty = true for (const context of this.dirty) { - for (const target of this.views.keys()) { - const previous = context.current.get(target) ?? null - const node = this.buildNode(context, target) - if (node === null && previous !== null) { - throw new Error( - `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, - ) - } - context.current.set(target, node) - if (node !== null) upsertsByTarget.get(target)?.push(node) + const target = context.definition.target + if (target === undefined || !this.views.has(target)) continue + const previous = context.current.get(target) ?? null + const node = this.buildNode(context, target) + if (node === null && previous !== null) { + throw new Error( + `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, + ) } + context.current.set(target, node) + if (node !== null) upsertsByTarget.get(target)?.push(node) } this.dirty.clear() const timelineDirty = this.timelineDirty @@ -323,6 +324,12 @@ export class ConversationNodeAssembler { return this.views.get(target)?.snapshot } + get( + target: Target, + ): ConversationViewSnapshotMap[Target] | undefined { + return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined + } + private sortedInputs(): ConversationEventInput[] { return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) } @@ -358,18 +365,19 @@ export class ConversationNodeAssembler { role: ConversationMatch['role'], ) => ConversationPublication, ): ConversationPublication { - let matched = false + const matchedTargets = new Set() let publication: ConversationPublication = 'none' for (const definition of this.eventDefinitions.entries()) { const result = definition.match(input.event) if (result === null) continue - matched = true + if (definition.target !== undefined) matchedTargets.add(definition.target) publication = maximumPublication(publication, accept(definition, result.id, result.role)) } - if (!matched) { - const fallback = this.eventDefinitions.fallbackEntry() - const result = fallback?.match(input.event) ?? null - if (fallback !== undefined && result !== null) { + const fallback = this.eventDefinitions.fallbackEntry() + const target = fallback?.target + if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) { + const result = fallback.match(input.event) + if (result !== null) { publication = maximumPublication(publication, accept(fallback, result.id, result.role)) } } @@ -697,7 +705,8 @@ export class ConversationNodeAssembler { } private buildNode(context: InternalContext, target: string): ConversationViewNode | null { - const node = context.definition.buildViewNode(contextSnapshot(context), target) + if (context.definition.target !== target || context.definition.buildViewNode === undefined) return null + const node = context.definition.buildViewNode(contextSnapshot(context)) if (node === null) return null if (node.key !== context.key) { throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`) diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a14d6fbb96..4397013dab 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -17,7 +17,7 @@ import type { import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' import type { - ChatConversationViewNode, ConversationTimelineSnapshot, + ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore, } from '../contract/conversation.ts' export type { TodoItem } @@ -384,6 +384,11 @@ export interface ChatSnapshot { const EMPTY_LIST: readonly never[] = [] const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } +/** Empty target store used by fixtures and Sessions without registered views. */ +export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = { + get: () => undefined, +} + /** Empty Chat target used before a view builder is registered. */ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { order: EMPTY_LIST, @@ -408,6 +413,8 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId + /** Registered target snapshots assembled from Session events. */ + views: ConversationViewSnapshotStore /** Final Chat target assembled from independently registered business Definitions. */ chat: ChatSnapshot /** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */ diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 9c1d86987c..8e984d3edc 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -727,6 +727,7 @@ export class Session implements SessionFace { const legacy = chat.legacy return { sessionId: this.sessionId, + views: this.conversation, chat, nodes: legacy.nodes, turnTimings: legacy.turnTimings, diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 7c8a40e06b..7c2bdf9bc6 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -126,6 +126,7 @@ describe('runtime client apply', () => { const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') const definition: ConversationNodeDefinition = { kind: 'registry-probe', + target: 'chat', match: () => null, start: () => null, update: context => context.state, diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts index 06dfd42567..50380a20a3 100644 --- a/packages/client/runtime/tests/conversation-assembler.spec.ts +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -30,10 +30,16 @@ interface TestSnapshot { } class TestEventDefinitions { + readonly definitions: readonly ConversationNodeDefinition[] + readonly fallback: ConversationNodeDefinition | undefined + constructor( - readonly definitions: readonly ConversationNodeDefinition[], - readonly fallback?: ConversationNodeDefinition, - ) {} + definitions: readonly ConversationNodeDefinition[], + fallback?: ConversationNodeDefinition, + ) { + this.definitions = definitions.map(asChatDefinition) + this.fallback = fallback === undefined ? undefined : asChatDefinition(fallback) + } entries(): readonly ConversationNodeDefinition[] { return this.definitions @@ -44,6 +50,12 @@ class TestEventDefinitions { } } +function asChatDefinition(definition: ConversationNodeDefinition): ConversationNodeDefinition { + return definition.buildViewNode === undefined || definition.target !== undefined + ? definition + : { ...definition, target: 'chat' } +} + class TestViewDefinitions { constructor(readonly definitions: readonly ConversationViewDefinition[]) {} @@ -93,7 +105,10 @@ function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | unde return assembler.snapshot('chat') as TestSnapshot | undefined } -function node(context: Parameters[0], data: unknown): ConversationViewNode { +function node( + context: Parameters>[0], + data: unknown, +): ConversationViewNode { return { key: context.key, kind: context.kind, diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 9f45b36c6a..0beaf1d5c2 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -13,6 +13,7 @@ import { FakeApiClient, ok } from './fake-api.ts' function eventDefinition(kind: string): ConversationNodeDefinition { return { kind, + target: 'chat', match: () => null, start: () => null, update: context => context.state, diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 36d9ce1b3d..0795d9a849 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -123,12 +123,13 @@ function testViewDefinition(): ConversationViewDefinition = { kind: 'runtime-test-event', + target: 'chat', match: event => ({ id: String(event.seq), role: 'start' }), start: (_context, match) => ({ event: match.event, view: match.view }), update: context => context.state, publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate', - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined || context.start === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined || context.start === undefined) return null return { key: context.key, kind: 'runtime-test-event', diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 7f65a0b5a3..3a44b05048 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -2,7 +2,9 @@ import type { ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -46,6 +48,7 @@ export interface SessionFixture { export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { return { sessionId, + views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 5baad5c37b..641a0287e4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -242,6 +242,7 @@ function projectAssistant(context: ConversationNodeContext): Ass /** Per-step Assistant streaming/final/interruption Definition. */ export const assistantDefinition: ConversationNodeDefinition = { kind: 'assistant-step', + target: 'chat', match: (event) => { if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } if (event.type === 'assistant/chunk' @@ -291,8 +292,7 @@ export const assistantDefinition: ConversationNodeDefinition = { value: projected.data, } }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const projected = projectAssistant(context) if (projected === undefined) return null if (projected.settled === undefined && !projected.visible) { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 1517fbe718..692666fb66 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -175,6 +175,7 @@ export function updateCompactionState( /** Slash-command lifecycle, including integrated manual compaction, Definition. */ export const commandDefinition: ConversationNodeDefinition = { kind: 'command', + target: 'chat', match: (event) => { if (event.type === 'command/run') { return { id: String(event.data.commandId), role: 'start' } @@ -202,8 +203,7 @@ export const commandDefinition: ConversationNodeDefinition = { } return updateCompactionState(context.state, match) }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state === undefined) return null if (state.command.name !== 'compact') { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index 2852019c3f..18f3205df7 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -30,6 +30,7 @@ function fallbackState(context: ConversationNodeContext): Compa /** Automatic compaction lifecycle and landed checkpoint Definition. */ export const compactionDefinition: ConversationNodeDefinition = { kind: 'compaction', + target: 'chat', match: (event) => { const checkpoint = compactSource(event) if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { @@ -47,8 +48,7 @@ export const compactionDefinition: ConversationNodeDefinition = }, start: () => ({}), update: (context, match) => updateCompactionState(context.state, match), - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state.checkpoint === undefined) return null const marker = compactSummary(state.summary, state.checkpoint) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts index 6309c35b94..79bc97e636 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -15,6 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { /** Unclaimed append-surface fallback Definition. */ export const unknownFallbackDefinition: ConversationNodeDefinition = { kind: 'unknown-surface', + target: 'chat', match: event => isAppendSurfaceEvent(event) ? { id: String(event.seq), role: 'start' } : null, @@ -26,7 +27,7 @@ export const unknownFallbackDefinition: ConversationNodeDefinition context.state, - buildViewNode: (context, target) => target !== 'chat' || context.state === undefined + buildViewNode: context => context.state === undefined ? null : chatNode(context, 'unknown', context.state.seq, context.state), } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index 92e611f77c..4d8fb6d3e2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -50,7 +50,6 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition context.state, publication: () => 'none', - buildViewNode: () => null, } } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index d57a6d9d96..085127f9c5 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -30,6 +30,7 @@ function isCompactionCheckpoint(event: Parameters = { kind: 'input-message', + target: 'chat', match: event => event.type === 'user/message' && isAppendSurfaceEvent(event) && !isCompactionCheckpoint(event) @@ -68,8 +69,8 @@ export const messageDefinition: ConversationNodeDefinition = { } }, update: context => context.state, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined) return null return chatNode(context, context.state.kind, context.state.seq, context.state) }, } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index 31b80075a8..4a0f9f9fed 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -40,6 +40,7 @@ function isClosed(location: ConversationLocation): boolean { /** Producer-correlated model retry chain Definition. */ export const retryDefinition: ConversationNodeDefinition = { kind: 'model-retry', + target: 'chat', match: (event) => { if (event.type === 'llm/retry') { const retryId: unknown = event.data.retryId @@ -70,8 +71,8 @@ export const retryDefinition: ConversationNodeDefinition = { attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt), } }, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null + buildViewNode: (context) => { + if (context.state === undefined || context.state.attempts.length === 0) return null const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const } const stateAttempts = context.state.attempts const attempts = stateAttempts.map((attempt, index) => diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index 46c838e980..0d6fb57cf3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -235,6 +235,7 @@ function fallbackState(context: ConversationNodeContext): ToolState | /** Root Tool lifecycle and nested Code Dispatch Definition. */ export const toolDefinition: ConversationNodeDefinition = { kind: 'tool-call', + target: 'chat', match: (event) => { if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { @@ -257,8 +258,7 @@ export const toolDefinition: ConversationNodeDefinition = { } return updateDispatch(context.state, match) }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state === undefined) return null const projected = projectBlock(state.root, state, interruption(context)) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts index 1f5a87add8..6242276d12 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -63,6 +63,7 @@ function fallbackState(context: ConversationNodeContext): TurnEr /** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */ export const turnErrorDefinition: ConversationNodeDefinition = { kind: 'turn-error', + target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'turn/end' && event.data.reason.kind === 'error') { @@ -82,8 +83,7 @@ export const turnErrorDefinition: ConversationNodeDefinition = { ? { ...context.state, hidden: true } : context.state }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state?.failure === undefined) return null const failure = state.failure diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 01bee27f3f..94fb72a383 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -151,6 +151,7 @@ function tailData(context: ConversationNodeContext): TurnTailChat /** Completed-turn footer Definition independent of any Assistant row. */ export const turnTailDefinition: ConversationNodeDefinition = { kind: 'turn-tail', + target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } @@ -179,8 +180,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { value, } }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx index 959a91c3ac..0b2d648661 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -7,6 +7,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -43,7 +44,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, chat: chatSnapshotFixture(), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index d6b4996567..fd306eb132 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -12,7 +12,9 @@ import type { UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait, +} from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, @@ -47,7 +49,7 @@ type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3acdc44a2a..6e0df4dd8c 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' @@ -48,7 +50,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 59c0f9eefa..b9aa56f7c8 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -37,7 +39,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 09cfe4ca66..bb6ba10990 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -8,7 +8,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -26,7 +28,7 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 416d2fe620..0722ccb2e1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -11,7 +11,9 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionsService, +} from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' @@ -112,7 +114,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, chat: EMPTY_CHAT_SNAPSHOT, + sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 68170b604f..4367a74dad 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -6,7 +6,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -33,7 +35,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 7d7596a49e..4651a27bea 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,7 +72,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 9151f88869..0061400e7a 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -97,6 +97,7 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ /** Turn-local successful mutation accumulator; it publishes no view Node. */ export const deliverablesDefinition: ConversationNodeDefinition = { kind: 'deliverables', + target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index f38faf8dd5..48303f2e54 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,7 +73,7 @@ interface TimelineSnapshot { class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } - fallbackEntry(): undefined { return undefined } + fallbackEntries(): readonly ConversationNodeDefinition[] { return [] } } class TestViewDefinitions { diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 1a7dd2d892..8840e6a19d 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -12,7 +12,8 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { - ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService, + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, + EMPTY_CONVERSATION_VIEWS, SlotsService, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, @@ -78,7 +79,8 @@ function snapshotWith( const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) return { - sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: nestedRunningCalls, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 600949f5e8..3990cba157 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -351,7 +353,8 @@ describe('DetailsPanel diff Output section', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index ef00460103..14baac6b9b 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -10,7 +10,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { @@ -297,7 +299,8 @@ describe('DetailsPanel Output section (read)', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index b41b665ea8..67d5eea6ca 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -9,7 +9,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -413,7 +415,8 @@ describe('DetailsPanel Output section (search)', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index a868ffa744..c0af5adff7 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -482,7 +484,8 @@ describe('DetailsPanel Output section', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 44f63147a0..67ec839471 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -10,7 +10,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -243,7 +245,8 @@ describe('DetailsPanel web Output section', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, From f479c60b6dabb0c8061ef7ac8dbeb34ba67cc37a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:38:22 +0800 Subject: [PATCH 02/17] feat(ui-trajectory): assemble registered conversation nodes --- .../src/client/TrajectoryView.tsx | 40 +- .../client/ui-trajectory/src/client/index.ts | 30 +- .../client/trajectory-assistant-definition.ts | 397 ++++++++++++++++++ .../trajectory-compaction-definition.ts | 139 ++++++ .../src/client/trajectory-contract.ts | 75 ++++ .../client/trajectory-definition-common.ts | 29 ++ .../client/trajectory-message-definitions.ts | 114 +++++ .../trajectory-request-header-definition.ts | 76 ++++ .../src/client/trajectory-snapshot-builder.ts | 222 ++++++++++ .../src/client/trajectory-tool-definition.ts | 250 +++++++++++ .../ui-trajectory/tests/client-bundle.spec.ts | 16 +- .../client/ui-trajectory/tests/views.spec.tsx | 141 ++++--- 12 files changed, 1425 insertions(+), 104 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-contract.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-definition-common.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 6d77f907f1..95476ac851 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,11 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, - SessionHistoryFace, SnapshotStore, + SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' import { deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, @@ -27,6 +27,7 @@ import { type TrajectoryTimeRange, } from './timeline.ts' import { trajectoryRecordId } from './trajectory-record.ts' +import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' import css from './views.module.css' const EMPTY_TURN_IDS: ReadonlySet = new Set() @@ -64,14 +65,12 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st : block.kind).join('\u0000') } -/** Session-history paging needed by the event-complete trajectory view. */ +/** Session-bound controls not already supplied by the conversation view slot. */ export interface TrajectoryViewInjected { hooks: { - history: SessionHistoryFace duration: SnapshotStore } - loadHistoryTail: (signal: AbortSignal) => Promise - loadOlderHistory: (signal: AbortSignal) => Promise + loadOlder: () => Promise setActualDuration: (actualDuration: boolean) => void } @@ -184,7 +183,7 @@ function mergeSearchMatches( } export function TrajectoryView({ - useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, + useSession, useDuration, loadOlder, setActualDuration, inspect, onInspectDone, }: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) @@ -204,23 +203,15 @@ export function TrajectoryView({ const [timelineRecordFocus, setTimelineRecordFocus] = useState<{ readonly index: number } | null>(null) - const inspection = useHistory(snapshot => snapshot.inspection) - const historyLoading = useHistory(snapshot => - snapshot.state === 'cold' || snapshot.state === 'loading') - const hasOlderHistory = useHistory(snapshot => snapshot.hasMore) - const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq) + const inspection = useSession(snapshot => + snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT) + const historyLoading = useSession(snapshot => + snapshot.openState === 'loading' || snapshot.loadingOlder) + const hasOlderHistory = useSession(snapshot => snapshot.hasMore) const nodes = inspection.eventNodes + const historyBaseSeq = nodes[0]?.seq ?? 0 const partial = inspection.partial const runningCalls = inspection.runningCalls - const loadHistoryTailRef = useRef(loadHistoryTail) - loadHistoryTailRef.current = loadHistoryTail - const historyControllerRef = useRef(null) - useEffect(() => { - const controller = new AbortController() - historyControllerRef.current = controller - void loadHistoryTailRef.current(controller.signal) - return () => { controller.abort() } - }, []) const requests = inspection.requests const callSchemas = inspection.callSchemas const historyContexts = inspection.contexts @@ -518,11 +509,8 @@ export function TrajectoryView({ } const loadEarlierHistory = useCallback(() => { - const signal = historyControllerRef.current?.signal - return signal?.aborted === false - ? loadOlderHistory(signal) - : Promise.resolve(false) - }, [loadOlderHistory]) + return loadOlder() + }, [loadOlder]) return (
diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index e1d3a5cc17..1f48a4711d 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -9,9 +9,15 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' +import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' +import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts' +import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts' +import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts' +import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts' -/** Required services: the conversation view slot and independent history source. */ -export const inject = ['slots', 'sessionHistory'] +/** Required services: the conversation slot, registries, and ordinary Session paging. */ +export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions'] /** * Client plugin body: register the trajectory view tab. The registration @@ -20,17 +26,29 @@ export const inject = ['slots', 'sessionHistory'] */ export function apply(ctx: Context): void { const duration = createTrajectoryDurationStore() + registerTrajectoryMessageDefinitions(ctx) + registerTrajectoryRequestHeaderDefinition(ctx) + registerTrajectoryAssistantDefinition(ctx) + registerTrajectoryToolDefinition(ctx) + registerTrajectoryCompactionDefinitions(ctx) + registerTrajectoryConversationView(ctx) ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory', inject: (sessionId: SessionId): TrajectoryViewInjected => { - const history = ctx.sessionHistory.source(sessionId) + const session = ctx.sessions.binding(sessionId)?.session + if (session === undefined) { + throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`) + } return { - hooks: { history, duration }, - loadHistoryTail: signal => history.loadTail(signal), - loadOlderHistory: signal => history.loadOlder(signal), + hooks: { duration }, + loadOlder: async () => { + const hadMore = session.getSnapshot().hasMore + await session.loadOlder() + return hadMore + }, setActualDuration: (value) => { duration.set(value) }, } }, diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts new file mode 100644 index 0000000000..8f0d9ff430 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -0,0 +1,397 @@ +import type { Context } from 'cordis' +import type { + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock, + toAssistantBlocks, +} from '@deepseek-ai/dsh-client-runtime/client' +import { trajectoryNode } from './trajectory-definition-common.ts' + +interface UsageValue { + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens?: number + readonly cacheWriteTokens?: number + readonly reasoningTokens?: number +} + +interface RetryValue { + readonly message: string + readonly retry: number + readonly maxRetries?: number + readonly delayMs: number +} + +interface AssistantState { + readonly turn: number + readonly step: number + readonly startSeq: number + readonly startTime: number + readonly started: boolean + readonly sawChunk: boolean + readonly blocks: readonly (AssistantBlock | undefined)[] + readonly firstVisibleSeq: number | undefined + readonly firstVisibleTime: number | undefined + readonly firstTokenTime: number | undefined + readonly final: ConversationMatch | undefined + readonly usage: UsageValue | undefined + readonly retry: RetryValue | undefined + readonly stepEnd: ConversationMatch | undefined +} + +function initialState( + turn: number, + step: number, + startSeq: number, + startTime: number, + started: boolean, +): AssistantState { + return { + turn, + step, + startSeq, + startTime, + started, + sawChunk: false, + blocks: [], + firstVisibleSeq: undefined, + firstVisibleTime: undefined, + firstTokenTime: undefined, + final: undefined, + usage: undefined, + retry: undefined, + stepEnd: undefined, + } +} + +function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] { + return blocks.filter((block): block is AssistantBlock => block !== undefined) +} + +function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'tool-call') return false + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue { + return { + inputTokens: (current?.inputTokens ?? 0) + next.inputTokens, + outputTokens: (current?.outputTokens ?? 0) + next.outputTokens, + ...(current?.cacheReadTokens === undefined && next.cacheReadTokens === undefined + ? {} + : { cacheReadTokens: (current?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0) }), + ...(current?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined + ? {} + : { cacheWriteTokens: (current?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0) }), + ...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined + ? {} + : { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }), + } +} + +function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState { + if (match.event.type !== 'assistant/chunk') return state + const chunk = match.event.data.chunk + if (chunk.type === 'usage') { + return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) } + } + const blocks = [...state.blocks] + switch (chunk.type) { + case 'block-start': + blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + break + case 'text-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { + kind: 'text', + text: (previous?.kind === 'text' ? previous.text : '') + chunk.text, + } + break + } + case 'reasoning-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { + kind: 'reasoning', + text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text, + } + break + } + case 'tool-call-delta': { + const previous = blocks[chunk.index] + const base = previous?.kind === 'tool-call' + ? previous + : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' } + blocks[chunk.index] = { + kind: 'tool-call', + callId: base.callId || String(chunk.id), + name: chunk.name ?? base.name, + argsRaw: base.argsRaw + chunk.argumentsDelta, + } + break + } + case 'block-end': + blocks[chunk.index] = toAssistantBlock(chunk.block) + break + default: + return { ...state, sawChunk: true } + } + const visible = hasVisibleContent(compactBlocks(blocks)) + return { + ...state, + sawChunk: true, + blocks, + ...(visible && state.firstVisibleSeq === undefined + ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time } + : {}), + ...(isTokenDelta(chunk) && state.firstTokenTime === undefined + ? { firstTokenTime: match.event.time } + : {}), + } +} + +function closedBoundary( + context: ConversationNodeContext, +): { seq: number; time: number } | undefined { + if (context.state?.stepEnd?.event.type === 'step/end') return context.state.stepEnd.event + const location: ConversationLocation | undefined = context.start?.location + ?? context.matches.at(-1)?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') + && location.turn.status === 'closed') return location.turn.end + return undefined +} + +function fallbackState(context: ConversationNodeContext): AssistantState | undefined { + let state: AssistantState | undefined + for (const match of context.matches) { + const event = match.event + if (event.type === 'assistant/chunk') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = updateChunk(state, match) + } else if (event.type === 'assistant/message') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = { + ...state, + blocks: toAssistantBlocks(event.data.message.content), + final: match, + usage: state.usage ?? event.data.usage, + } + } else if (event.type === 'step/end' && state !== undefined) { + state = { ...state, stepEnd: match } + } + } + return state +} + +function finalNode( + state: AssistantState, + context: ConversationNodeContext, +): AssistantMessageNode | undefined { + const final = state.final + if (final?.event.type === 'assistant/message') { + const event = final.event + return { + kind: 'assistant', + seq: event.seq, + time: event.time, + turn: state.turn, + step: state.step, + blocks: toAssistantBlocks(event.data.message.content), + usage: event.data.usage, + provenance: { + provider: event.data.message.source.provider, + model: event.data.message.source.model, + }, + timing: { + stepStartTime: state.started ? state.startTime : null, + firstTokenTime: state.firstTokenTime ?? null, + completedTime: event.time, + }, + } + } + const boundary = closedBoundary(context) + const blocks = compactBlocks(state.blocks) + if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined + return { + kind: 'assistant', + seq: boundary.seq - 0.9, + time: boundary.time, + turn: state.turn, + step: state.step, + blocks, + interrupted: true, + } +} + +function assistantRequest( + state: AssistantState, + node: AssistantMessageNode | undefined, + boundary: { seq: number; time: number } | undefined, +): Extract | undefined { + if (!state.started) return undefined + const status = node !== undefined && node.interrupted !== true + ? 'complete' + : state.retry !== undefined || boundary !== undefined ? 'error' : 'running' + return { + purpose: 'assistant', + startSeq: state.startSeq, + turn: state.turn, + step: state.step, + startedAt: state.startTime, + completedAt: node?.time ?? boundary?.time ?? null, + status, + ...(state.retry === undefined + ? {} + : { + error: state.retry.message, + retry: state.retry.retry, + ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), + retryDelayMs: state.retry.delayMs, + }), + ...(node === undefined || node.interrupted === true + ? {} + : { + resultSeq: node.seq, + ...(node.provenance === undefined ? {} : { provenance: node.provenance }), + }), + ...(state.usage === undefined ? {} : { usage: state.usage }), + } +} + +/** Trajectory-owned Assistant streaming, settlement, and request lifecycle. */ +const trajectoryAssistantDefinition: ConversationNodeDefinition = { + kind: 'trajectory-assistant-step', + target: 'trajectory', + match: (event) => { + if (event.type === 'step/start') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + } + if (event.type === 'assistant/chunk' + || event.type === 'assistant/message' + || event.type === 'llm/retry' + || event.type === 'step/end') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') { + throw new Error('trajectory-assistant-step start requires step/start') + } + return initialState( + match.event.data.turn, + match.event.data.step, + match.event.seq, + match.event.time, + true, + ) + }, + update: (context, match) => { + if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match) + if (match.event.type === 'assistant/message') { + return { + ...context.state, + blocks: toAssistantBlocks(match.event.data.message.content), + final: match, + usage: context.state.usage ?? match.event.data.usage, + } + } + if (match.event.type === 'step/end') return { ...context.state, stepEnd: match } + if (match.event.type !== 'llm/retry') return context.state + const data = match.event.data + return { + ...initialState( + context.state.turn, + context.state.step, + context.state.startSeq, + context.state.startTime, + true, + ), + firstTokenTime: context.state.firstTokenTime, + usage: context.state.usage, + retry: { + message: displayFailureMessage(data.failure), + retry: data.retry, + ...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}), + delayMs: data.delayMs, + }, + } + }, + publication: (match) => { + if (match.event.type === 'step/start') return 'none' + if (match.event.type !== 'assistant/chunk') return 'immediate' + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + }, + buildViewNode: (context) => { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const node = finalNode(state, context) + const boundary = closedBoundary(context) + const partial: PartialAssistant | null = node === undefined && boundary === undefined && state.sawChunk + ? { turn: state.turn, step: state.step, blocks: compactBlocks(state.blocks) } + : null + const request = assistantRequest(state, node, boundary) + if (node === undefined && partial === null && request === undefined) return null + return trajectoryNode(context, state.startSeq, { + kind: 'assistant', + ...(node === undefined ? {} : { node }), + partial, + ...(request === undefined ? {} : { request }), + }) + }, +} + +interface TurnEndState { + readonly turn: number + readonly seq: number + readonly time: number + readonly error?: string +} + +const trajectoryTurnEndDefinition: ConversationNodeDefinition = { + kind: 'trajectory-turn-end', + target: 'trajectory', + match: event => event.type === 'turn/end' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => { + if (match.event.type !== 'turn/end') { + throw new Error('trajectory-turn-end start requires turn/end') + } + const reason = match.event.data.reason + return { + turn: match.event.data.turn, + seq: match.event.seq, + time: match.event.time, + ...(reason.kind === 'error' ? { error: displayFailureMessage(reason.error) } : {}), + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'turn-end', + turn: context.state.turn, + time: context.state.time, + ...(context.state.error === undefined ? {} : { error: context.state.error }), + }), +} + +/** Register the Trajectory Assistant lifecycle. */ +export function registerTrajectoryAssistantDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryAssistantDefinition) + ctx.conversationEvents.register(trajectoryTurnEndDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts new file mode 100644 index 0000000000..65bab06059 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -0,0 +1,139 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeDefinition, RequestView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-compact/types' +import { trajectoryNode } from './trajectory-definition-common.ts' + +interface CompactionState { + readonly start: ConversationMatch + readonly summary?: ConversationMatch + readonly end?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +function checkpointId( + event: Parameters[0], +): string | undefined { + if (event.type !== 'user/message') return undefined + const source = event.data.source as unknown as { + readonly kind?: unknown + readonly plugin?: unknown + readonly compactionId?: unknown + } + return source.kind === 'plugin' && source.plugin === 'compact' + && typeof source.compactionId === 'string' && source.compactionId !== '' + ? source.compactionId + : undefined +} + +function eventCompactionId( + event: Parameters[0], +): string | undefined { + if (event.type !== 'compact/start' + && event.type !== 'compact/summary' + && event.type !== 'compact/end') return undefined + const value: unknown = event.data.compactionId + return typeof value === 'string' && value !== '' ? value : undefined +} + +function requestFromState( + state: CompactionState, +): Extract | undefined { + const start = state.start.event + if (start.type !== 'compact/start') return undefined + const summary = state.summary?.event + const end = state.end?.event + const checkpoint = state.checkpoint?.event + return { + purpose: 'compaction', + startSeq: start.seq, + turn: start.data.turn, + step: 0, + startedAt: start.time, + completedAt: end?.type === 'compact/end' ? end.time : null, + status: end?.type !== 'compact/end' + ? 'running' + : end.data.error === undefined ? 'complete' : 'error', + ...(end?.type === 'compact/end' && end.data.error !== undefined + ? { error: end.data.error } + : {}), + ...(summary?.type !== 'compact/summary' + ? {} + : { + resultSeq: summary.seq, + summary: summary.data.summary, + ...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }), + provenance: { provider: summary.data.provider, model: summary.data.model }, + requestConfig: { + provider: summary.data.provider, + model: summary.data.model, + purpose: 'compaction', + ...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }), + }, + ...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }), + }), + ...(checkpoint?.type === 'user/message' ? { replacementSeq: checkpoint.seq } : {}), + } +} + +const trajectoryCompactionDefinition: ConversationNodeDefinition = { + kind: 'trajectory-compaction', + target: 'trajectory', + match: (event) => { + const compactId = eventCompactionId(event) + if (compactId !== undefined) { + return { id: compactId, role: event.type === 'compact/start' ? 'start' : 'update' } + } + const checkpoint = checkpointId(event) + return checkpoint === undefined ? null : { id: checkpoint, role: 'update' } + }, + start: (_context, match) => { + if (match.event.type !== 'compact/start') { + throw new Error('trajectory-compaction start requires compact/start') + } + return { start: match } + }, + update: (context, match) => { + if (match.event.type === 'compact/summary') return { ...context.state, summary: match } + if (match.event.type === 'compact/end') return { ...context.state, end: match } + return checkpointId(match.event) === undefined + ? context.state + : { ...context.state, checkpoint: match } + }, + buildViewNode: (context) => { + if (context.state === undefined) return null + const request = requestFromState(context.state) + return request === undefined + ? null + : trajectoryNode(context, request.startSeq, { kind: 'compaction', request }) + }, +} + +interface SessionEndState { + readonly seq: number + readonly time: number +} + +const trajectorySessionEndDefinition: ConversationNodeDefinition = { + kind: 'trajectory-session-end', + target: 'trajectory', + match: event => event.type === 'session/end-seed' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => ({ seq: match.event.seq, time: match.event.time }), + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'session-end', + seq: context.state.seq, + time: context.state.time, + }), +} + +/** Register Trajectory compaction requests and session boundaries. */ +export function registerTrajectoryCompactionDefinitions(ctx: Context): void { + ctx.conversationEvents.register(trajectoryCompactionDefinition) + ctx.conversationEvents.register(trajectorySessionEndDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts new file mode 100644 index 0000000000..e261eeb9fc --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -0,0 +1,75 @@ +import type { + AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode, + ConversationPromptSnapshot, ConversationViewNode, PartialAssistant, + RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Request-header facts retained by the Trajectory target. */ +export interface TrajectoryRequestHeaderState { + readonly seq: number + readonly time: number + readonly prompt: ConversationPromptSnapshot + readonly change?: RequestPromptChange + readonly location: ConversationLocation +} + +/** One independently assembled contribution to the legacy Trajectory ledger. */ +export type TrajectoryContribution = + | { + readonly kind: 'node' + readonly node: ConversationNode + } + | { + readonly kind: 'assistant' + readonly node?: AssistantMessageNode + readonly partial: PartialAssistant | null + readonly request?: Extract + } + | { + readonly kind: 'tool' + readonly root: ToolCallBlock + } + | { + readonly kind: 'request-header' + readonly header: TrajectoryRequestHeaderState + } + | { + readonly kind: 'compaction' + readonly request: Extract + } + | { + readonly kind: 'session-end' + readonly seq: number + readonly time: number + } + | { + readonly kind: 'turn-end' + readonly turn: number + readonly time: number + readonly error?: string + } + +/** Target envelope consumed by the Trajectory snapshot builder. */ +export interface TrajectoryConversationViewNode extends ConversationViewNode { + readonly target: 'trajectory' + readonly anchorSeq: number + readonly data: TrajectoryContribution +} + +/** Stage-oriented Trajectory data assembled from registered business Contexts. */ +export interface TrajectorySnapshot { + readonly eventNodes: readonly ConversationNode[] + readonly contexts: readonly ConversationContext[] + readonly requests: readonly RequestView[] + readonly callSchemas: ReadonlyMap + readonly interruptedNodes: readonly ConversationNode[] + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationViewSnapshotMap { + /** Independently assembled data consumed by the Trajectory view. */ + trajectory: TrajectorySnapshot + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts new file mode 100644 index 0000000000..d11034b9b9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -0,0 +1,29 @@ +import type { + ConversationLocation, ConversationNodeContext, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + TrajectoryContribution, TrajectoryConversationViewNode, +} from './trajectory-contract.ts' + +/** Resolve the best loaded Location for one target-local Context. */ +export function trajectoryContextLocation( + context: ConversationNodeContext, +): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +/** Wrap one contribution in the Engine-owned target envelope. */ +export function trajectoryNode( + context: ConversationNodeContext, + anchorSeq: number, + data: TrajectoryContribution, +): TrajectoryConversationViewNode { + return { + key: context.key, + kind: context.kind, + id: context.id, + target: 'trajectory', + anchorSeq, + data, + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts new file mode 100644 index 0000000000..5f6b203e28 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -0,0 +1,114 @@ +import type { Context } from 'cordis' +import type { + ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, + SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + contextForm, contextProvenance, +} from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-agent/types' +import { trajectoryNode } from './trajectory-definition-common.ts' + +interface InboxIdentity { + readonly id: string +} + +interface InboxSplice { + readonly start: number + readonly removedCount?: number + readonly inserted: readonly InboxIdentity[] + readonly outcome?: 'canceled' +} + +interface InboxState { + readonly pending: readonly InboxIdentity[] + readonly claimed: ReadonlySet +} + +type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode + +function applySplice( + previous: ConversationPreviousContext | undefined, + splice: InboxSplice, +): InboxState { + const pending = [...(previous?.state.pending ?? [])] + const claimed = new Set(previous?.state.claimed ?? []) + const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) + for (const identity of splice.inserted) claimed.delete(identity.id) + if (splice.outcome !== 'canceled') { + for (const identity of removed) claimed.add(identity.id) + } + return { pending, claimed } +} + +const trajectoryInboxDefinition: ConversationNodeDefinition = { + kind: 'trajectory-inbox-next-step', + match: event => event.type === 'agent/inbox/spliced' + && event.data.target === 'next-step' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') { + throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced') + } + return applySplice( + reader.previous('trajectory-inbox-next-step'), + match.event.data, + ) + }, + update: context => context.state, + publication: () => 'none', +} + +const trajectoryMessageDefinition: ConversationNodeDefinition = { + kind: 'trajectory-input-message', + target: 'trajectory', + match: event => event.type === 'user/message' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'user/message') { + throw new Error('trajectory-input-message start requires user/message') + } + const event = match.event + 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), + } + } + const claimed = reader.previous('trajectory-inbox-next-step') + ?.state.claimed.has(String(event.data.id)) === true + return claimed + ? { + kind: 'steering', + messageId: event.data.id, + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + : { + kind: 'user', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), +} + +/** Register Trajectory-owned inbox classification and message records. */ +export function registerTrajectoryMessageDefinitions(ctx: Context): void { + ctx.conversationEvents.register(trajectoryInboxDefinition) + ctx.conversationEvents.register(trajectoryMessageDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts new file mode 100644 index 0000000000..a6ec4e4597 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -0,0 +1,76 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, + RequestPromptChange, +} from '@deepseek-ai/dsh-client-runtime/client' +import { trajectoryNode } from './trajectory-definition-common.ts' +import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts' + +function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot { + if (match.event.type !== 'request/header') { + throw new Error('trajectory-request-header start requires request/header') + } + const header = match.event.data.header + const tools: unknown = header.tools + return { + config: header.config, + system: header.system ?? '', + tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [], + } +} + +function promptChange( + previous: ConversationPromptSnapshot | undefined, + prompt: ConversationPromptSnapshot, + match: ConversationMatch, +): RequestPromptChange | undefined { + if (match.event.type !== 'request/header') return undefined + if (previous === undefined && match.event.data.reason !== 'initial') return undefined + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous !== undefined && !systemChanged && !toolsChanged) return undefined + return { + seq: match.event.seq, + time: match.event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged ? 'system' : 'tools', + ...(previous === undefined ? {} : { previous }), + } +} + +const trajectoryRequestHeaderDefinition: ConversationNodeDefinition = { + kind: 'trajectory-request-header', + target: 'trajectory', + match: event => event.type === 'request/header' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + const prompt = requestPrompt(match) + const previous = reader.previous('trajectory-request-header') + ?.state.prompt + const change = promptChange(previous, prompt, match) + return { + seq: match.event.seq, + time: match.event.time, + prompt, + location: match.location, + ...(change === undefined ? {} : { change }), + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'request-header', + header: context.state, + }), +} + +/** Register Trajectory request-header facts. */ +export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryRequestHeaderDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts new file mode 100644 index 0000000000..613cd1e750 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -0,0 +1,222 @@ +import type { Context } from 'cordis' +import type { + AssistantMessageNode, ConversationNode, ConversationPromptSnapshot, + ConversationViewBuilder, ConversationViewDefinition, RequestView, + ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + TrajectoryConversationViewNode, TrajectoryRequestHeaderState, + TrajectorySnapshot, +} from './trajectory-contract.ts' + +const EMPTY_LIST: readonly never[] = [] +const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }] + +/** Stable empty target used until a Session has assembled Trajectory records. */ +export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { + eventNodes: EMPTY_LIST, + contexts: EMPTY_CONTEXTS, + requests: EMPTY_LIST, + callSchemas: new Map(), + interruptedNodes: EMPTY_LIST, + partial: null, + runningCalls: EMPTY_LIST, +} + +function coordinates( + header: TrajectoryRequestHeaderState, +): { turn?: number; step?: number } { + const location = header.location + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + +function headerFor( + request: Extract, + headers: readonly TrajectoryRequestHeaderState[], +): TrajectoryRequestHeaderState | undefined { + const exact = headers.findLast((header) => { + const location = coordinates(header) + return location.turn === request.turn && location.step === request.step + }) + return exact ?? headers.findLast(header => header.seq < request.startSeq) +} + +function applyHeader( + request: Extract, + header: TrajectoryRequestHeaderState | undefined, +): Extract { + return header === undefined + ? request + : { + ...request, + prompt: header.prompt, + requestConfig: header.prompt.config, + ...(header.change === undefined ? {} : { promptChange: header.change }), + } +} + +function withRequestConfig( + node: AssistantMessageNode, + prompt: ConversationPromptSnapshot | undefined, +): AssistantMessageNode { + return prompt === undefined ? node : { ...node, requestConfig: prompt.config } +} + +function captureSchemas( + block: ToolCallBlock, + tools: readonly ConversationPromptSnapshot['tools'][number][], + output: Map, +): void { + const name = 'kind' in block ? block.call?.name : block.name + const schema = name === undefined || name === null + ? undefined + : tools.find(candidate => candidate.name === name) + if (schema !== undefined) output.set(block.callId, schema) + for (const child of block.subCalls) captureSchemas(child, tools, output) +} + +function interruptCompactions( + requests: RequestView[], + boundaries: readonly { seq: number; time: number }[], +): void { + for (const boundary of boundaries) { + const index = requests.findLastIndex(request => + request.purpose === 'compaction' + && request.startSeq < boundary.seq + && request.status === 'running') + const request = requests[index] + if (request?.purpose !== 'compaction') continue + requests[index] = { + ...request, + completedAt: boundary.time, + status: 'error', + error: 'Compaction was interrupted before completion.', + } + } +} + +function applyTurnErrors( + requests: RequestView[], + endings: readonly { turn: number; time: number; error?: string }[], +): void { + for (const ending of endings) { + if (ending.error === undefined) continue + const index = requests.findLastIndex(request => + request.purpose === 'assistant' && request.turn === ending.turn) + const request = requests[index] + if (request?.purpose !== 'assistant') continue + requests[index] = { + ...request, + completedAt: request.completedAt ?? ending.time, + status: 'error', + error: ending.error, + } + } +} + +/** Simple keyed adapter retaining the old Trajectory snapshot and stage layout. */ +export class TrajectorySnapshotBuilder implements ConversationViewBuilder< + TrajectoryConversationViewNode, + TrajectorySnapshot +> { + private readonly nodes = new Map() + readonly empty = EMPTY_TRAJECTORY_SNAPSHOT + + replace(input: { + readonly nodes: readonly TrajectoryConversationViewNode[] + }): TrajectorySnapshot { + this.nodes.clear() + for (const node of input.nodes) this.nodes.set(node.key, node) + return this.snapshot() + } + + apply(input: { + readonly upserts: readonly TrajectoryConversationViewNode[] + }): TrajectorySnapshot { + for (const node of input.upserts) this.nodes.set(node.key, node) + return this.snapshot() + } + + private snapshot(): TrajectorySnapshot { + const contributions = [...this.nodes.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) + const headers = contributions.flatMap(node => node.data.kind === 'request-header' + ? [node.data.header] + : []) + const finalized: ConversationNode[] = [] + const requests: RequestView[] = [] + const boundaries: { seq: number; time: number }[] = [] + const turnEndings: { turn: number; time: number; error?: string }[] = [] + const callSchemas = new Map() + let partial: TrajectorySnapshot['partial'] = null + const runningCalls: TrajectorySnapshot['runningCalls'][number][] = [] + + for (const contribution of contributions) { + const data = contribution.data + if (data.kind === 'node') { + finalized.push(data.node) + continue + } + if (data.kind === 'assistant') { + const header = data.request === undefined ? undefined : headerFor(data.request, headers) + if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) + if (data.partial !== null) partial = data.partial + if (data.request !== undefined) requests.push(applyHeader(data.request, header)) + continue + } + if (data.kind === 'tool') { + if ('kind' in data.root) finalized.push(data.root) + else runningCalls.push(data.root) + const header = headers.findLast(candidate => candidate.seq < contribution.anchorSeq) + if (header !== undefined) captureSchemas(data.root, header.prompt.tools, callSchemas) + continue + } + if (data.kind === 'compaction') { + requests.push(data.request) + continue + } + if (data.kind === 'session-end') { + boundaries.push({ seq: data.seq, time: data.time }) + continue + } + if (data.kind === 'turn-end') { + turnEndings.push({ + turn: data.turn, + time: data.time, + ...(data.error === undefined ? {} : { error: data.error }), + }) + } + } + + requests.sort((left, right) => left.startSeq - right.startSeq) + interruptCompactions(requests, boundaries) + applyTurnErrors(requests, turnEndings) + finalized.sort((left, right) => left.seq - right.seq) + const eventNodes = finalized + return { + eventNodes, + contexts: [{ id: 0, nodes: eventNodes }], + requests, + callSchemas, + interruptedNodes: EMPTY_LIST, + partial, + runningCalls, + } + } +} + +/** Trajectory target factory preserving the existing stage-oriented view model. */ +export const trajectoryViewDefinition: ConversationViewDefinition< + TrajectoryConversationViewNode, + TrajectorySnapshot +> = { + target: 'trajectory', + create: () => new TrajectorySnapshotBuilder(), +} + +/** Register the legacy-shape Trajectory target builder. */ +export function registerTrajectoryConversationView(ctx: Context): void { + ctx.conversationViews.register(trajectoryViewDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts new file mode 100644 index 0000000000..353d89070b --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -0,0 +1,250 @@ +import type { Context } from 'cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-tools/types' +import { trajectoryNode } from './trajectory-definition-common.ts' + +const MAX_DEPTH = 256 + +interface ToolState { + readonly rootId: string + readonly calls: ReadonlyMap + readonly children: ReadonlyMap + readonly parents: ReadonlyMap +} + +interface DispatchData { + readonly parentCallId: string + readonly subCallId: string + readonly name: string + readonly arguments: unknown + readonly isError?: boolean + readonly content?: ToolResultNode['content'] +} + +function rootCall(match: ConversationMatch): RunningToolCall { + if (match.event.type !== 'tool/call') { + throw new Error('trajectory-tool-call start requires tool/call') + } + return { + callId: String(match.event.data.callId), + name: match.event.data.name, + argsRaw: match.event.data.arguments, + turn: match.event.data.turn, + step: match.event.data.step, + time: match.event.time, + callView: match.view?.for === 'call' ? match.view.view : null, + subCalls: [], + } +} + +function rootResult( + match: ConversationMatch, + previous?: RunningToolCall, +): ToolResultNode | undefined { + if (match.event.type !== 'tool/result') return undefined + const result = match.event.data.message.content[0] + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: String(match.event.data.message.source.callId), + call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw }, + callTime: previous?.time ?? null, + content: result.content, + isError: result.isError === true, + ...(match.event.data.error === undefined ? {} : { error: match.event.data.error }), + meta: match.event.data.meta, + callView: previous?.callView ?? null, + resultView: match.view?.for === 'result' ? match.view.view : null, + subCalls: [], + } +} + +function locationTurn(match: ConversationMatch): number { + return match.location.kind === 'step' || match.location.kind === 'turn' + ? match.location.turn.turn + : 0 +} + +function locationStep(match: ConversationMatch): number { + return match.location.kind === 'step' ? match.location.step.step : 0 +} + +function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { + return { + callId: data.subCallId, + name: data.name, + argsRaw: JSON.stringify(data.arguments), + turn: locationTurn(match), + step: locationStep(match), + time: match.event.time, + callView: null, + subCalls: [], + } +} + +function childResult( + match: ConversationMatch, + data: DispatchData, + previous?: ToolCallBlock, +): ToolResultNode { + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, + callTime: previous === undefined || 'kind' in previous ? null : previous.time, + content: data.content ?? [], + isError: data.isError === true, + callView: null, + resultView: null, + subCalls: [], + } +} + +function acceptsEdge(state: ToolState, parent: string, child: string): boolean { + if (parent === child || state.parents.has(child)) return false + let cursor: string | undefined = parent + for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) { + if (cursor === child) return false + cursor = state.parents.get(cursor) + } + return cursor === undefined +} + +function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { + const event = match.event + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state + const data = event.data + const parentId = String(data.parentCallId) + const childId = String(data.subCallId) + const siblings = state.children.get(parentId) ?? [] + const index = siblings.indexOf(childId) + if (index < 0 && !acceptsEdge(state, parentId, childId)) return state + if (event.type === 'tool/code-dispatch-start' && index >= 0) return state + + const calls = new Map(state.calls) + calls.set(childId, event.type === 'tool/code-dispatch-start' + ? childCall(match, data) + : childResult(match, data, calls.get(childId))) + if (index >= 0) return { ...state, calls } + const children = new Map(state.children) + children.set(parentId, [...siblings, childId]) + const parents = new Map(state.parents) + parents.set(childId, parentId) + return { ...state, calls, children, parents } +} + +function interruption( + context: ConversationNodeContext, +): { seq: number; time: number } | undefined { + const location = context.start?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') + && location.turn.status === 'closed') return location.turn.end + return undefined +} + +function projectCall( + state: ToolState, + callId: string, + interruptedAt: { seq: number; time: number } | undefined, + visited = new Set(), + depth = 1, +): ToolCallBlock | undefined { + const block = state.calls.get(callId) + if (block === undefined) return undefined + if (visited.has(callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } + const nextVisited = new Set(visited) + nextVisited.add(callId) + const subCalls = (state.children.get(callId) ?? []) + .flatMap((childId) => { + const child = projectCall(state, childId, interruptedAt, nextVisited, depth + 1) + return child === undefined ? [] : [child] + }) + if ('kind' in block || interruptedAt === undefined) return { ...block, subCalls } + return { + kind: 'tool-result', + seq: interruptedAt.seq - 0.8, + time: interruptedAt.time, + callId: block.callId, + call: { name: block.name, argsRaw: block.argsRaw }, + callTime: block.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: block.callView, + resultView: null, + subCalls, + } +} + +function fallbackState(context: ConversationNodeContext): ToolState | undefined { + const resultMatch = context.matches.find(match => match.event.type === 'tool/result') + const root = resultMatch === undefined ? undefined : rootResult(resultMatch) + if (root === undefined) return undefined + let state: ToolState = { + rootId: root.callId, + calls: new Map([[root.callId, root]]), + children: new Map(), + parents: new Map(), + } + for (const match of context.matches) state = updateDispatch(state, match) + return state +} + +/** Trajectory-owned root Tool lifecycle with nested Code Dispatch calls. */ +const trajectoryToolDefinition: ConversationNodeDefinition = { + kind: 'trajectory-tool-call', + target: 'trajectory', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') { + return { id: String(event.data.message.source.callId), role: 'update' } + } + if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + const rootCallId: unknown = event.data.rootCallId + return typeof rootCallId === 'string' && rootCallId !== '' + ? { id: rootCallId, role: 'update' } + : null + } + return null + }, + start: (_context, match) => { + const root = rootCall(match) + return { + rootId: root.callId, + calls: new Map([[root.callId, root]]), + children: new Map(), + parents: new Map(), + } + }, + update: (context, match) => { + if (match.event.type !== 'tool/result') return updateDispatch(context.state, match) + const previous = context.state.calls.get(context.state.rootId) + const running = previous !== undefined && !('kind' in previous) ? previous : undefined + const result = rootResult(match, running) + if (result === undefined) return context.state + const calls = new Map(context.state.calls) + calls.set(context.state.rootId, result) + return { ...context.state, calls } + }, + buildViewNode: (context) => { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const root = projectCall(state, state.rootId, interruption(context)) + if (root === undefined) return null + const anchorSeq = context.start?.event.seq + ?? ('kind' in root ? root.seq : context.matches[0]?.event.seq ?? 0) + return trajectoryNode(context, anchorSeq, { kind: 'tool', root }) + }, +} + +/** Register the Trajectory Tool lifecycle. */ +export function registerTrajectoryToolDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryToolDefinition) +} diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 511cf03b5c..902363b719 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -10,7 +10,9 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory' @@ -61,21 +63,25 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'sessionHistory']) + expect(surface.inject).toEqual([ + 'slots', 'conversationEvents', 'conversationViews', 'sessions', + ]) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { const { surface } = await loadArtifact() const ctx = new Context() const slots = new SlotsService(ctx) + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() // The conversation entry's role: the ring must be declared before riders land. slots.register({ name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin reads sessionHistory for its per-session history source; - // slot availability is tracked by slots.inject. - ctx.provide('sessionHistory', {}) + // Paging is session-owned; this registration-only probe never renders the + // entry, so the binding stays deliberately empty. + ctx.provide('sessions', { binding: () => undefined }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 26adf85ba8..ab3f23d829 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -13,12 +13,15 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, + EMPTY_CHAT_SNAPSHOT, +} from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection, - SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState, + ConversationSnapshot, RequestView, + SessionId, SessionListState, SnapshotStore, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import { @@ -35,9 +38,11 @@ import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' +import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId +const sessionSnapshots = new WeakMap>() const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record)[key] ?? key @@ -67,37 +72,55 @@ const NODES = [ function historySnapshot( nodes: ConversationSnapshot['nodes'], - inspection: Partial = {}, -): SessionHistorySnapshot { + inspection: Partial = {}, +): ConversationSnapshot { + const trajectory: TrajectorySnapshot = { + eventNodes: nodes, + contexts: [{ id: 0, nodes }], + requests: [], + callSchemas: new Map(), + interruptedNodes: [], + partial: null, + runningCalls: [], + ...inspection, + } return { - state: 'ready', - error: null, + sessionId: SID, + views: { + get: target => target === 'trajectory' ? trajectory : undefined, + } as ConversationSnapshot['views'], + chat: EMPTY_CHAT_SNAPSHOT, + nodes, + turnTimings: new Map(), + turnEnds: new Map(), + partial: trajectory.partial, + runningCalls: trajectory.runningCalls, + pending: [], + queue: [], + running: false, + subagent: null, + composerPhase: 'active', + removed: false, + openState: 'open', + openError: null, hasMore: false, - baseSeq: nodes[0]?.seq ?? 0, - inspection: { - eventNodes: nodes, - contexts: [{ id: 0, nodes }], - requests: [], - callSchemas: new Map(), - interruptedNodes: [], - partial: null, - runningCalls: [], - ...inspection, - }, + loadingOlder: false, + promptError: null, + blank: nodes.length === 0, + lastAgentError: null, } } function standaloneHistory( - snapshot: SessionHistorySnapshot, + snapshot: ConversationSnapshot, ): Pick< ComponentProps, - 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory' + 'useSession' | 'loadOlder' > { const store = createSnapshotStore(snapshot) return { - useHistory: bindSnapshotSelector(store), - loadHistoryTail: () => Promise.resolve(), - loadOlderHistory: () => Promise.resolve(false), + useSession: bindSnapshotSelector(store), + loadOlder: () => Promise.resolve(false), } } @@ -112,11 +135,8 @@ function standaloneDuration(): Pick< } function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore({ - nodes, pending: [], partial: null, - runningCalls: [] as ConversationSnapshot['runningCalls'], - }) - return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } + const store = createSnapshotStore(historySnapshot(nodes)) + return { store, useSession: bindSnapshotSelector(store) as UseSession } } /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ @@ -149,16 +169,19 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { async function bench(snapshot = historySnapshot(NODES)) { const ctx = new Context() const slots = new SlotsService(ctx) - const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve()) - const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false)) - const historyStore = createSnapshotStore(snapshot) - const history: SessionHistoryFace = { - sessionId: SID, - getSnapshot: () => historyStore.getSnapshot(), - subscribe: listener => historyStore.subscribe(listener), - loadTail: loadHistoryTail, - loadOlder: loadOlderHistory, + const loadOlder = vi.fn(() => Promise.resolve()) + const sessionStore = createSnapshotStore(snapshot) + const session = { + getSnapshot: () => sessionStore.getSnapshot(), + subscribe: (listener: () => void) => sessionStore.subscribe(listener), + loadOlder, } + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() + ctx.provide('sessions', { + binding: () => ({ session }), + }) + sessionSnapshots.set(slots, sessionStore) // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ name: 'root', @@ -167,10 +190,9 @@ async function bench(snapshot = historySnapshot(NODES)) { const chatBody = vi.fn(() =>
) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) - ctx.provide('sessionHistory', { source: () => history }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } + return { ctx, slots, fiber, loadOlder } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -181,13 +203,8 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = createSnapshotStore({ - running: false, removed: false, promptError: null, nodes, - pending: [], - openState: 'open' as const, hasMore: true, loadingOlder: false, - partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], - }) - const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession + const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) + const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession const chat = createChatStore().create() const views = { list: () => tabsOf(slots), @@ -215,10 +232,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES ? (() => { const trajectory = injected as TrajectoryViewInjected return { - loadHistoryTail: trajectory.loadHistoryTail, - loadOlderHistory: trajectory.loadOlderHistory, + loadOlder: trajectory.loadOlder, setActualDuration: trajectory.setActualDuration, - useHistory: bindSnapshotSelector(trajectory.hooks.history), useDuration: bindSnapshotSelector(trajectory.hooks.duration), } })() @@ -322,13 +337,9 @@ describe('tab switching in ConversationRoot', () => { fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() - await vi.waitFor(() => { - expect(b.loadHistoryTail).toHaveBeenCalledOnce() - }) - const signal = b.loadHistoryTail.mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) + expect(b.loadOlder).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) - expect(signal?.aborted).toBe(true) + expect(b.loadOlder).not.toHaveBeenCalled() }) it('opens a local record inspector and switches payload tabs without opening chat details', async () => { @@ -1162,9 +1173,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) @@ -1196,9 +1206,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) const row = screen.getByRole('row', { name: /stable rewind response/ }) @@ -1225,9 +1234,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) fireEvent.click(screen.getByRole('row', { name: /selected current response/ })) @@ -1274,9 +1282,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) From c828d38f5193c775132dbc300e8bf582f23d6eb4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:38:50 +0800 Subject: [PATCH 03/17] refactor(client-runtime): remove legacy history fold --- .../src/client/contract/session-history.ts | 43 -- .../client/session-history/history-fold.ts | 428 ----------------- .../src/client/session-history/service.ts | 66 --- .../src/client/session-history/source.ts | 432 ------------------ .../runtime/src/client/sessions/history.ts | 121 ----- .../src/client/sessions/request-inspection.ts | 328 +------------ .../client/runtime/tests/history-fold.spec.ts | 232 ---------- .../runtime/tests/request-inspection.spec.ts | 319 ------------- .../tests/session-history-source.spec.ts | 180 -------- scripts/gen-cordis-catalog.ts | 1 - 10 files changed, 4 insertions(+), 2146 deletions(-) delete mode 100644 packages/client/runtime/src/client/contract/session-history.ts delete mode 100644 packages/client/runtime/src/client/session-history/history-fold.ts delete mode 100644 packages/client/runtime/src/client/session-history/service.ts delete mode 100644 packages/client/runtime/src/client/session-history/source.ts delete mode 100644 packages/client/runtime/src/client/sessions/history.ts delete mode 100644 packages/client/runtime/tests/history-fold.spec.ts delete mode 100644 packages/client/runtime/tests/request-inspection.spec.ts delete mode 100644 packages/client/runtime/tests/session-history-source.spec.ts diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts deleted file mode 100644 index a48e89585e..0000000000 --- a/packages/client/runtime/src/client/contract/session-history.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - RpcError, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' -import type { SessionHistoryInspection } from '../sessions/history.ts' -import type { ObservableSnapshot } from './store.ts' - -/** Observable state of one independently loaded session history ledger. */ -export interface SessionHistorySnapshot { - state: 'cold' | 'loading' | 'ready' | 'error' - error: RpcError | null - hasMore: boolean - /** Absolute sequence of the first loaded raw event, or zero for an empty window. */ - baseSeq: number - inspection: SessionHistoryInspection -} - -/** Read-only history source addressed by session id. */ -export interface SessionHistoryFace - extends ObservableSnapshot { - readonly sessionId: SessionId - /** - * Load the current tail without reading older pages. - * @param signal - Consumer lifetime. - * @returns When the tail is ready or loading fails. - */ - loadTail(signal?: AbortSignal): Promise - /** - * Prepend one older page when the current window has a predecessor. - * @param signal - Consumer lifetime. - * @returns Whether the loaded window advanced. - */ - loadOlder(signal?: AbortSignal): Promise -} - -/** Runtime service resolving independent history sources. */ -export interface ISessionHistory { - /** - * Resolve the identity-stable source for a session. - * @param sessionId - Host session identity. - * @returns The source owned outside Session and SessionManager. - */ - source(sessionId: SessionId): SessionHistoryFace -} diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts deleted file mode 100644 index 42aaa37027..0000000000 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ /dev/null @@ -1,428 +0,0 @@ -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { - SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, -} from '@deepseek-ai/dsh-session/surface' -import type { - HistoryEntry, ToolCallView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' -import type { - AssistantRequestConfig, AssistantTiming, ConversationNode, - PartialAssistant, RunningToolCall, -} from '../sessions/conversation.ts' -import { toAssistantBlocks } from '../sessions/conversation.ts' -import { contextForm, contextProvenance } from '../sessions/context-provenance.ts' -import { SteeringHistory } from '../sessions/steering-history.ts' -import type { - ConversationContext, ConversationContextOriginKind, -} from '../sessions/conversation-context.ts' -import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts' -import { PartialAccumulator } from '../sessions/partial.ts' -import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts' -import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts' -import { ToolCallTree } from '../sessions/tool-call-tree.ts' - -interface CallIndexEntry { - name: string - argsRaw: string - time: number - callView: ToolCallView | null -} - -interface FoldedContext { - generation: number - nodes: readonly number[] - originSeq?: number -} - -/** Immutable conversation projections derived only from the history source. */ -export interface ConversationHistoryProjection { - eventNodes: readonly ConversationNode[] - contexts: readonly ConversationContext[] - interruptedNodes: readonly ConversationNode[] - partial: PartialAssistant | null - runningCalls: readonly RunningToolCall[] -} - -function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { - if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false - return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq -} - -function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { - if (event?.type !== 'user/message') return 'rewrite' - const source = event.data.source - if (typeof source === 'object' && 'kind' in source && 'plugin' in source) { - if (source.plugin === 'compact') return 'compaction' - if (source.plugin === 'rewind') return 'rewind' - } - return 'rewrite' -} - -function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { - const replay: SessionEvent[] = [] - const originalSeqs: number[] = [] - const rebasedSeqByOriginal = new Map() - const surface = new SurfaceManager(replay) - const contexts: FoldedContext[] = [] - let generation = 0 - let originSeq: number | undefined - const originalNodes = () => surface.nodes.map((seq) => { - const original = originalSeqs[seq] - if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`) - return original - }) - for (const event of events) { - if (!isSurfaceEvent(event)) continue - if (event.surfaceOp !== 'append') { - contexts.push({ - generation, - nodes: originalNodes(), - ...(originSeq === undefined ? {} : { originSeq }), - }) - generation++ - originSeq = event.seq - } - const rebasedSeq = replay.length - const { - sourceEventSeqs: rawSources, - ...eventWithoutSources - } = event as SessionEvent & { sourceEventSeqs?: readonly number[] } - const mappedSourceEventSeqs = rawSources?.flatMap((seq) => { - const rebased = rebasedSeqByOriginal.get(seq) - return rebased === undefined ? [] : [rebased] - }) - const sourceEventSeqs = mappedSourceEventSeqs?.length === 0 - ? undefined - : mappedSourceEventSeqs - const surfaceOp = event.surfaceOp === 'append' - ? event.surfaceOp - : { - ...event.surfaceOp, - start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start, - end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end, - } - originalSeqs.push(event.seq) - rebasedSeqByOriginal.set(event.seq, rebasedSeq) - replay.push({ - ...eventWithoutSources, - seq: rebasedSeq, - surfaceOp, - ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }), - } as SessionEvent) - } - contexts.push({ - generation, - nodes: originalNodes(), - ...(originSeq === undefined ? {} : { originSeq }), - }) - return contexts -} - -// History projection owns its node mapping so Chat's live adapter remains free -// of inspection metadata and lifecycle coupling. -/* jscpd:ignore-start */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - resultView: ToolResultView | null, - assistantTiming: AssistantTiming | undefined, - requestConfig: AssistantRequestConfig | undefined, - steering: boolean, -): ConversationNode { - switch (event.type) { - case 'user/message': - 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, - provenance: { - provider: event.data.message.source.provider, - model: event.data.message.source.model, - }, - ...(requestConfig === undefined ? {} : { requestConfig }), - ...(assistantTiming === undefined ? {} : { timing: assistantTiming }), - } - 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 === undefined ? null : { name: call.name, argsRaw: call.argsRaw }, - 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: [], - } - } - default: - return { - kind: 'unknown', seq: event.seq, time: event.time, - type: event.type, data: (event as { data?: unknown }).data, - } - } -} -/* jscpd:ignore-end */ - -interface TransientProjection extends Pick< - ConversationHistoryProjection, - 'interruptedNodes' | 'partial' | 'runningCalls' -> { - toolCallTree: ToolCallTree -} - -function projectTransient(entries: readonly HistoryEntry[]): TransientProjection { - let partial: PartialAccumulator | null = null - const openCalls = new Map() - const interruptedNodes: ConversationNode[] = [] - const toolCallTree = new ToolCallTree() - - for (const entry of entries) { - const { event } = entry - if (toolCallTree.apply(event)) continue - switch (event.type) { - case 'assistant/chunk': { - const { turn, step, chunk } = event.data - if (partial === null || partial.turn !== turn || partial.step !== step) { - partial = new PartialAccumulator(turn, step) - } - partial.push(chunk) - break - } - case 'assistant/message': - if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null - break - case 'tool/call': - // History reconstructs its own in-flight index; this intentionally - // mirrors the published Chat node shape, not Chat's mutable state. - /* jscpd:ignore-start */ - openCalls.set(String(event.data.callId), { - callId: String(event.data.callId), - name: event.data.name, - argsRaw: event.data.arguments, - turn: event.data.turn, - step: event.data.step, - time: event.time, - callView: entry.view?.for === 'call' ? entry.view.view : null, - subCalls: [], - }) - /* jscpd:ignore-end */ - break - case 'tool/result': - openCalls.delete(String(event.data.message.source.callId)) - break - case 'turn/end': { - if (partial !== null && partial.turn === event.data.turn) { - const { blocks } = partial.toPartial() - const visible = blocks.some(block => - block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true) - if (visible) { - interruptedNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, time: event.time, - turn: partial.turn, step: partial.step, blocks, interrupted: true, - }) - } - partial = null - } - let callOffset = 0 - for (const [callId, call] of openCalls) { - if (call.turn !== event.data.turn) continue - openCalls.delete(callId) - // Interrupted terminal nodes are reconstructed independently so a - // Trajectory replay cannot observe Session's frozen-node lifecycle. - /* jscpd:ignore-start */ - interruptedNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, - time: event.time, - callId, - call: { name: call.name, argsRaw: call.argsRaw }, - callTime: call.time, - content: [], - isError: true, - error: { name: 'Interrupted', code: 'interrupted' }, - callView: call.callView, - resultView: null, - subCalls: [], - }) - /* jscpd:ignore-end */ - } - break - } - default: - break - } - } - - return { - interruptedNodes, - partial: partial?.toPartial() ?? null, - runningCalls: [...openCalls.values()], - toolCallTree, - } -} - -/** - * Project one immutable history ledger without reading or mutating Chat state. - * @param entries - Contiguous history entries in sequence order. - * @returns Event order, context lineage, and transient tail state. - */ -export function projectConversationHistory( - entries: readonly HistoryEntry[], -): ConversationHistoryProjection { - const events = entries.map(entry => entry.event) - const steeringHistory = new SteeringHistory() - const steeringSeqs = new Set() - for (const event of events) { - if (steeringHistory.apply(event)) steeringSeqs.add(event.seq) - } - const baseSeq = events[0]?.seq ?? 0 - const eventsBySeq = new Map(events.map(event => [event.seq, event])) - const callIndex = new Map() - const resultViews = new Map() - const assistantSteps = new Map() - const assistantTimings = new Map() - const assistantRequestConfigs = new Map() - const promptsByContext = new Map() - let activeRequestConfig: AssistantRequestConfig | undefined - let activePrompt: ConversationPromptSnapshot | undefined - let contextGeneration = 0 - - for (const [index, event] of events.entries()) { - const view = entries[index]?.view - if (event.type === 'tool/call') { - callIndex.set(String(event.data.callId), { - name: event.data.name, - argsRaw: event.data.arguments, - time: event.time, - callView: view?.for === 'call' ? view.view : null, - }) - } else if (event.type === 'tool/result' && view?.for === 'result') { - resultViews.set(event.seq, view.view) - } - if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { - contextGeneration++ - if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt) - } - indexAssistantStepTiming(assistantSteps, event) - if (event.type === 'request/header') { - activeRequestConfig = event.data.header.config - activePrompt = { - config: event.data.header.config, - system: event.data.header.system ?? '', - tools: event.data.header.tools ?? [], - } - promptsByContext.set(contextGeneration, activePrompt) - } else if (event.type === 'assistant/message') { - assistantTimings.set( - event.seq, - settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time), - ) - if (activeRequestConfig !== undefined) { - assistantRequestConfigs.set(event.seq, activeRequestConfig) - } - } - } - - const nodeCache = new Map() - const materialize = (seq: number): ConversationNode | undefined => { - const cached = nodeCache.get(seq) - if (cached !== undefined) return cached - const event = eventsBySeq.get(seq) - if (event === undefined || !isSurfaceEligibleType(event.type)) return - const node = materializeNode( - event, - callIndex, - resultViews.get(seq) ?? null, - assistantTimings.get(seq), - assistantRequestConfigs.get(seq), - steeringSeqs.has(seq), - ) - nodeCache.set(seq, node) - return node - } - const eventNodes = events.flatMap((event) => { - const node = materialize(event.seq) - return node === undefined ? [] : [node] - }) - - let contexts: readonly ConversationContext[] - if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) { - contexts = [{ - id: 0, - ...(activePrompt === undefined ? {} : { prompt: activePrompt }), - nodes: eventNodes, - }] - } else { - try { - contexts = foldContexts(events).map((context): ConversationContext => { - const nodes = context.nodes.flatMap((seq) => { - const node = materialize(seq) - return node === undefined ? [] : [node] - }) - const prompt = promptsByContext.get(context.generation) - if (context.originSeq === undefined) { - return { - id: context.generation, - ...(prompt === undefined ? {} : { prompt }), - nodes, - } - } - const originEvent = eventsBySeq.get(context.originSeq) - return { - id: context.generation, - parentId: context.generation - 1, - origin: contextOriginKind(originEvent), - originSeq: context.originSeq, - ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), - ...(prompt === undefined ? {} : { prompt }), - nodes, - } - }) - } catch (error) { - console.error('[web-runtime] history surface fold failed, using event order:', error) - contexts = [{ - id: 0, - ...(activePrompt === undefined ? {} : { prompt: activePrompt }), - nodes: eventNodes, - }] - } - } - - const transient = projectTransient(entries) - const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes) - const projectedContexts = contexts.map((context): ConversationContext => { - const nodes = transient.toolCallTree.projectNodes(context.nodes) - return nodes === context.nodes ? context : { ...context, nodes } - }) - return { - eventNodes: projectedEventNodes, - contexts: projectedContexts, - interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes), - partial: transient.partial, - runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls), - } -} diff --git a/packages/client/runtime/src/client/session-history/service.ts b/packages/client/runtime/src/client/session-history/service.ts deleted file mode 100644 index 4705b6566d..0000000000 --- a/packages/client/runtime/src/client/session-history/service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Context } from '@deepseek-ai/cordis' -import type { - HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' -import type { - ISessionHistory, SessionHistoryFace, -} from '../contract/session-history.ts' -import { SessionHistorySource } from './source.ts' - -/** Root registry and frame router for independent inspection histories. */ -export class SessionHistoryService implements ISessionHistory { - private readonly sources = new Map() - - /** - * @param ctx - Client root context. - * @param api - Shared wire client. - */ - constructor(ctx: Context, private readonly api: IApiClient) { - ctx.reflect.provide('sessionHistory', this, undefined) - } - - /** - * Resolve one identity-stable history source. - * @param sessionId - Host session identity. - * @returns Source independent from SessionManager. - */ - source(sessionId: SessionId): SessionHistoryFace { - let source = this.sources.get(sessionId) - if (source === undefined) { - source = new SessionHistorySource(sessionId, this.api) - this.sources.set(sessionId, source) - } - return source - } - - /** - * Route history-relevant mux frames only to an existing source. - * @param envelope - Validated mux envelope. - */ - handleMuxEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - if (frame.type === 'stream/error') return - this.sources.get(frame.sessionId)?.handleMuxFrame(frame) - } - - /** - * Drop a removed session's independent history source. - * @param envelope - Validated host envelope. - */ - handleHostEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - if (frame.type !== 'host/session-removed') return - this.sources.get(frame.sessionId)?.dispose() - this.sources.delete(frame.sessionId) - } - - /** Invalidate requests from the dead connection generation. */ - handleDisconnected(): void { - for (const source of this.sources.values()) source.handleDisconnected() - } - - /** Rebuild every previously activated source from the new generation. */ - handleConnected(): void { - for (const source of this.sources.values()) source.resync() - } -} diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts deleted file mode 100644 index 44e760b2b2..0000000000 --- a/packages/client/runtime/src/client/session-history/source.ts +++ /dev/null @@ -1,432 +0,0 @@ -import type { - HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { - SessionHistoryFace, SessionHistorySnapshot, -} from '../contract/session-history.ts' -import { - compactHistoryInspectionEntries, createHistoryInspection, -} from '../sessions/history.ts' -import { Notifier } from '../sessions/notifier.ts' -import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts' - -const HISTORY_PAGE_MESSAGES = 50 - -function isAborted(signal: AbortSignal | undefined): boolean { - return signal?.aborted === true -} - -/** Independent raw-history owner used only by inspection consumers. */ -export class SessionHistorySource implements SessionHistoryFace { - private entries: HistoryEntry[] = [] - private inspectionEntries: readonly HistoryEntry[] = [] - private baseSeq = 0 - private hasMore = false - private state: SessionHistorySnapshot['state'] = 'cold' - private error: RpcError | null = null - private generation = 0 - private persistentConsumer = false - private readonly consumerSignals = new Set() - private openPromise: Promise | null = null - private olderPromise: Promise | null = null - private stitching = false - private liveBuffer: HistoryEntry[] = [] - private subscribedLastSeq: number | null = null - private inspectionCache: { - entries: readonly HistoryEntry[] - value: SessionHistorySnapshot['inspection'] - } | null = null - private streamPublishToken: object | null = null - private streamPartial: PartialAccumulator | null = null - private snapshotCache: SessionHistorySnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) - - /** - * @param sessionId - Host session identity. - * @param api - Shared wire client. - */ - constructor( - readonly sessionId: SessionId, - private readonly api: IApiClient, - ) { - this.snapshotCache = this.buildSnapshot() - } - - /** - * Subscribe to ledger changes. - * @param listener - Change callback. - * @returns Unsubscribe function. - */ - subscribe(listener: () => void): () => void { - return this.notifier.subscribe(listener) - } - - /** - * Read the cached ledger snapshot. - * @returns Stable snapshot until the source changes. - */ - getSnapshot(): SessionHistorySnapshot { - this.notifier.ensureFresh() - return this.snapshotCache - } - - /** - * Load the current tail without reading older pages. - * @param signal - Consumer lifetime. - * @returns When the tail is ready or loading fails. - */ - async loadTail(signal?: AbortSignal): Promise { - if (isAborted(signal)) return - this.trackConsumer(signal) - await this.open() - } - - /** - * Prepend one older page when the current window has a predecessor. - * @param signal - Consumer lifetime. - * @returns Whether the loaded window advanced. - */ - async loadOlder(signal?: AbortSignal): Promise { - if (isAborted(signal)) return false - this.trackConsumer(signal) - await this.open() - if (isAborted(signal)) return false - const previousBaseSeq = this.baseSeq - await this.loadOlderPage() - return this.baseSeq !== previousBaseSeq - } - - /** - * Route a relevant mux frame without involving the Chat session. - * @param frame - Session-addressed frame. - */ - handleMuxFrame(frame: MuxFrame): void { - if (frame.type === 'session/subscribed') { - this.subscribedLastSeq = frame.lastSeq - return - } - if (frame.type !== 'session/event') return - this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) }) - } - - /** Invalidate dead-generation requests while retaining the last readable snapshot. */ - handleDisconnected(): void { - this.generation++ - this.openPromise = null - this.olderPromise = null - this.stitching = false - this.liveBuffer = [] - this.subscribedLastSeq = null - if (this.state !== 'cold') { - this.state = 'cold' - this.error = null - this.publishDirtyNow() - } - } - - /** Rebuild an activated ledger from the new connection generation. */ - resync(): void { - if (!this.hasConsumer()) return - this.generation++ - this.openPromise = null - this.olderPromise = null - this.stitching = false - this.liveBuffer = [] - this.subscribedLastSeq = null - this.entries = [] - this.inspectionEntries = [] - this.baseSeq = 0 - this.hasMore = false - this.state = 'cold' - this.error = null - this.publishDirtyNow() - void this.open() - } - - /** Stop future refresh work after the host removes the session. */ - dispose(): void { - this.persistentConsumer = false - this.consumerSignals.clear() - this.generation++ - this.openPromise = null - this.olderPromise = null - this.liveBuffer = [] - this.streamPublishToken = null - this.streamPartial = null - } - - private open(): Promise { - if (this.state === 'ready') return Promise.resolve() - if (this.openPromise !== null) return this.openPromise - const generation = this.generation - const operation = this.doOpen(generation) - const settled = operation.finally(() => { - if (this.openPromise === settled) this.openPromise = null - }) - this.openPromise = settled - return settled - } - - private trackConsumer(signal: AbortSignal | undefined): void { - if (signal === undefined) { - this.persistentConsumer = true - return - } - if (this.consumerSignals.has(signal)) return - this.consumerSignals.add(signal) - signal.addEventListener('abort', () => { - this.consumerSignals.delete(signal) - }, { once: true }) - } - - private hasConsumer(): boolean { - return this.persistentConsumer || this.consumerSignals.size > 0 - } - - private async doOpen(generation: number): Promise { - this.state = 'loading' - this.error = null - this.publishDirtyNow() - try { - let { result } = await this.api.sessions.history({ - sessionId: this.sessionId, - maxMessages: HISTORY_PAGE_MESSAGES, - }) - if (generation !== this.generation) return - if (!result.ok) { - this.state = 'error' - this.error = result.error - return - } - this.installTail(result.value.events, result.value.hasMore, true) - const tailSeq = this.tailSeq() - if ( - this.subscribedLastSeq !== null - && tailSeq !== null - && this.subscribedLastSeq > tailSeq - ) { - result = (await this.api.sessions.history({ - sessionId: this.sessionId, - maxMessages: HISTORY_PAGE_MESSAGES, - })).result - if (generation !== this.generation) return - if (result.ok) this.installTail(result.value.events, result.value.hasMore, true) - } - this.state = 'ready' - } catch (error) { - if (generation !== this.generation) return - this.state = 'error' - const folded = transportError(error) - /* v8 ignore next -- transportError always returns the error branch. */ - this.error = folded.ok ? null : folded.error - } finally { - if (generation === this.generation) this.publishDirtyNow() - } - } - - private loadOlderPage(): Promise { - if (this.olderPromise !== null) return this.olderPromise - if (this.state !== 'ready' || !this.hasMore) return Promise.resolve() - const generation = this.generation - const operation = (async () => { - try { - const { result } = await this.api.sessions.history({ - sessionId: this.sessionId, - beforeSeq: this.baseSeq, - maxMessages: HISTORY_PAGE_MESSAGES, - }) - if (generation !== this.generation || this.state !== 'ready' || !result.ok) return - const older = result.value.events - if (older.length === 0) { - this.hasMore = result.value.hasMore - return - } - const tail = older.at(-1) - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { - console.error( - `[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`, - ) - this.hasMore = false - return - } - this.entries = [...older, ...this.entries] - this.inspectionEntries = compactHistoryInspectionEntries([...this.entries]) - this.baseSeq = older[0]?.event.seq ?? this.baseSeq - this.hasMore = result.value.hasMore - } catch (error) { - console.error('[web-runtime] inspection history paging failed:', error) - } - })() - const settled = operation.finally(() => { - if (this.olderPromise !== settled) return - this.olderPromise = null - this.publishDirtyNow() - }) - this.olderPromise = settled - return settled - } - - private installTail( - tail: readonly HistoryEntry[], - hasMore: boolean, - replace: boolean, - ): void { - if (replace) { - this.entries = [...tail] - this.hasMore = hasMore - } else { - const firstSeq = tail[0]?.event.seq - const prefix = firstSeq === undefined - ? this.entries - : this.entries.filter(entry => entry.event.seq < firstSeq) - this.entries = [...prefix, ...tail] - } - this.baseSeq = this.entries[0]?.event.seq ?? 0 - this.inspectionEntries = compactHistoryInspectionEntries([...this.entries]) - const buffered = this.liveBuffer - this.liveBuffer = [] - for (const entry of buffered) this.appendLive(entry) - this.publishDirtyNow() - } - - private acceptLive(entry: HistoryEntry): void { - if (this.state === 'loading' || this.stitching) { - this.liveBuffer.push(entry) - return - } - if (this.state !== 'ready') return - const tailSeq = this.tailSeq() - if (tailSeq !== null && entry.event.seq > tailSeq + 1) { - this.liveBuffer.push(entry) - void this.repairGap() - return - } - if ( - entry.event.type === 'assistant/chunk' - && entry.event.data.chunk.type !== 'usage' - ) { - if (!this.appendIncrementalChunk(entry, entry.event)) return - this.publishStreamDirty() - return - } - this.appendLive(entry) - this.publishDirtyNow() - } - - private appendLive(entry: HistoryEntry): void { - const tailSeq = this.tailSeq() - if (tailSeq !== null && entry.event.seq <= tailSeq) return - this.entries.push(entry) - this.inspectionEntries = [...this.inspectionEntries, entry] - if (entry.event.type === 'assistant/message') { - this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries) - } - } - - /** Append a chunk against the cached finalized projection; false means no visible publish. */ - private appendIncrementalChunk( - entry: HistoryEntry, - event: SessionEvent<'assistant/chunk'>, - ): boolean { - const { turn, step, chunk } = event.data - if (!isVisibleAssistantChunk(chunk.type)) { - const inspection = this.currentInspection() - this.appendLive(entry) - this.inspectionCache = { entries: this.inspectionEntries, value: inspection } - return false - } - const base = this.currentInspection() - if ( - this.streamPartial === null - || this.streamPartial.turn !== turn - || this.streamPartial.step !== step - ) { - const current = base.partial - this.streamPartial = new PartialAccumulator( - turn, - step, - current?.turn === turn && current.step === step ? current.blocks : [], - ) - } - this.streamPartial.push(chunk) - this.appendLive(entry) - this.inspectionCache = { - entries: this.inspectionEntries, - value: { ...base, partial: this.streamPartial.toPartial() }, - } - return true - } - - /** Coalesce token-stream projection and rendering work to one publish per browser frame. */ - private publishStreamDirty(): void { - if (this.streamPublishToken !== null) return - const token = {} - this.streamPublishToken = token - const publish = () => { - if (this.streamPublishToken !== token) return - this.streamPublishToken = null - this.notifier.markDirty() - } - if (typeof globalThis.requestAnimationFrame === 'function') { - globalThis.requestAnimationFrame(publish) - } else { - queueMicrotask(publish) - } - } - - /** Publish structural changes immediately and invalidate an older scheduled stream publish. */ - private publishDirtyNow(): void { - this.streamPublishToken = null - this.streamPartial = null - this.notifier.markDirty() - } - - private async repairGap(): Promise { - if (this.stitching) return - this.stitching = true - const generation = this.generation - try { - const { result } = await this.api.sessions.history({ - sessionId: this.sessionId, - maxMessages: HISTORY_PAGE_MESSAGES, - }) - if (result.ok && generation === this.generation && this.state === 'ready') { - this.installTail(result.value.events, result.value.hasMore, false) - } - } catch (error) { - console.error('[web-runtime] inspection history gap repair failed:', error) - } finally { - if (generation === this.generation) this.stitching = false - } - } - - private tailSeq(): number | null { - return this.entries.at(-1)?.event.seq ?? null - } - - private buildSnapshot(): SessionHistorySnapshot { - return { - state: this.state, - error: this.error, - hasMore: this.hasMore, - baseSeq: this.baseSeq, - inspection: this.currentInspection(), - } - } - - /** Inspection pinned to the source's current immutable entry array. */ - private currentInspection(): SessionHistorySnapshot['inspection'] { - if (this.inspectionCache?.entries !== this.inspectionEntries) { - const entries = this.inspectionEntries - this.inspectionCache = { - entries, - value: createHistoryInspection(() => entries), - } - } - return this.inspectionCache.value - } -} diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts deleted file mode 100644 index 8609481d33..0000000000 --- a/packages/client/runtime/src/client/sessions/history.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ToolSchema } from '@deepseek-ai/dsh-llm/types' -import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import type { - ConversationNode, PartialAssistant, RunningToolCall, -} from './conversation.ts' -import type { ConversationContext } from './conversation-context.ts' -import { projectConversationHistory } from '../session-history/history-fold.ts' -import { inspectRequests, type RequestView } from './request-inspection.ts' - -function assistantStepKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -function isFirstTokenCandidate(entry: HistoryEntry): boolean { - const event = entry.event - if (event.type !== 'assistant/chunk') return false - switch (event.data.chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return event.data.chunk.text !== '' - case 'tool-call-delta': - return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined - default: - return false - } -} - -/** Lazily derived inspection data for one immutable session-history window. */ -export interface SessionHistoryInspection { - eventNodes: readonly ConversationNode[] - contexts: readonly ConversationContext[] - requests: readonly RequestView[] - callSchemas: ReadonlyMap - interruptedNodes: readonly ConversationNode[] - partial: PartialAssistant | null - runningCalls: readonly RunningToolCall[] -} - -/** - * Remove completed-step token payloads that no inspection projection reads. - * The first visible token preserves timing, usage chunks preserve accounting, - * and unfinished steps retain every chunk for live or interrupted content. - * @param entries - Contiguous raw history entries in sequence order. - * @returns A projection-equivalent, usually much smaller entry ledger. - */ -export function compactHistoryInspectionEntries( - entries: readonly HistoryEntry[], -): readonly HistoryEntry[] { - const completedSteps = new Set() - for (const { event } of entries) { - if (event.type === 'assistant/message') { - completedSteps.add(assistantStepKey(event.data.turn, event.data.step)) - } - } - - const firstTokenSteps = new Set() - const compacted: HistoryEntry[] = [] - let changed = false - for (const entry of entries) { - const event = entry.event - if (event.type !== 'assistant/chunk') { - compacted.push(entry) - continue - } - const key = assistantStepKey(event.data.turn, event.data.step) - if (!completedSteps.has(key) || event.data.chunk.type === 'usage') { - compacted.push(entry) - continue - } - if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) { - firstTokenSteps.add(key) - compacted.push(entry) - } else { - changed = true - } - } - return changed ? compacted : entries -} - -/** - * Create a lazy inspection projection over an immutable history window. - * Conversation consumers retain the cheap wrapper; only Trajectory snapshots - * the entries and replays event order and request lifecycle state. - * @param loadEntries - Lazily snapshots contiguous raw entries in sequence order. - * @returns Lazy, memoized inspection fields for that exact window. - */ -export function createHistoryInspection( - loadEntries: () => readonly HistoryEntry[], -): SessionHistoryInspection { - let entries: readonly HistoryEntry[] | undefined - let conversation: ReturnType | undefined - let requests: ReturnType | undefined - const historyEntries = () => entries ??= loadEntries() - const conversationProjection = () => - conversation ??= projectConversationHistory(historyEntries()) - const requestProjection = () => - requests ??= inspectRequests(historyEntries()) - return { - get eventNodes() { - return conversationProjection().eventNodes - }, - get contexts() { - return conversationProjection().contexts - }, - get interruptedNodes() { - return conversationProjection().interruptedNodes - }, - get partial() { - return conversationProjection().partial - }, - get runningCalls() { - return conversationProjection().runningCalls - }, - get requests() { - return requestProjection().requests - }, - get callSchemas() { - return requestProjection().callSchemas - }, - } -} diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 162f34d5ff..9856bce2bc 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -1,17 +1,7 @@ -// Request-centric inspection read model. Ordinary generation and compaction -// calls share one chronological projection; presentation-specific grouping -// remains in the trajectory consumer. - -import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types' -import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-compact/types' -import type {} from '@deepseek-ai/dsh-llm-retry/types' -import type {} from '@deepseek-ai/dsh-tools/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { AssistantProvenanceView, AssistantRequestConfig, } from './conversation.ts' -import { displayFailureMessage } from './failure-display.ts' export type { AssistantProvenanceView, AssistantRequestConfig, @@ -54,7 +44,7 @@ interface RequestViewBase { resultSeq?: number } -/** One ordinary assistant generation reconstructed from durable request events. */ +/** One ordinary assistant generation assembled from durable request events. */ interface AssistantRequestView extends RequestViewBase { purpose: 'assistant' turn: number @@ -85,321 +75,11 @@ interface CompactionRequestView extends RequestViewBase { rawOutput?: readonly ContentBlock[] } -/** One provider request reconstructed from durable request lifecycle events. */ +/** One provider request assembled from durable request lifecycle events. */ export type RequestView = AssistantRequestView | CompactionRequestView -/** Immutable request-centric projection derived from one history window. */ +/** Request data consumed by the stage-oriented Trajectory layout. */ export interface RequestInspectionSnapshot { requests: readonly RequestView[] callSchemas: ReadonlyMap } - -/** - * Derive the request-centric read model from one immutable history window. - * Compaction participates as a request purpose rather than a parallel - * top-level collection. A leading resume/change header exposes its prompt but - * cannot project a change until the preceding header enters the window. - * @param entries - Contiguous raw session history. - * @returns Requests and call-time schemas derived from that history. - */ -export function inspectRequests( - entries: readonly HistoryEntry[], -): RequestInspectionSnapshot { - const events = entries.map(entry => entry.event) - return { - requests: deriveRequests(events), - callSchemas: deriveCallSchemas(events), - } -} - -function requestKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage { - const previous = current as TokenUsage | undefined - return { - inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens, - outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens, - ...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined - ? {} - : { - cacheReadTokens: - (previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0), - }), - ...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined - ? {} - : { - cacheWriteTokens: - (previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0), - }), - ...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined - ? {} - : { - reasoningTokens: - (previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0), - }), - } -} - -function deriveCallSchemas( - events: readonly SessionEvent[], -): ReadonlyMap { - let active = new Map() - const calls = new Map() - const capture = (callId: string, name: string): void => { - if (calls.has(callId)) return - const schema = active.get(name) - if (schema !== undefined) calls.set(callId, schema) - } - for (const event of events) { - if (event.type === 'request/header') { - const tools: unknown = event.data.header.tools - active = new Map( - Array.isArray(tools) - ? (tools as ToolSchema[]).map(schema => [schema.name, schema]) - : [], - ) - continue - } - if (event.type === 'tool/call') { - capture(String(event.data.callId), event.data.name) - continue - } - if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { - capture(String(event.data.subCallId), event.data.name) - } - } - return calls -} - -function promptChange( - previous: ConversationPromptSnapshot | undefined, - prompt: ConversationPromptSnapshot, - event: SessionEvent<'request/header'>, -): RequestPromptChange | undefined { - if (previous === undefined && event.data.reason !== 'initial') return - const systemChanged = previous !== undefined && previous.system !== prompt.system - const toolsChanged = previous !== undefined - && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) - if (previous !== undefined && !systemChanged && !toolsChanged) return - return { - seq: event.seq, - time: event.time, - kind: previous === undefined - ? 'initial' - : systemChanged && toolsChanged - ? 'system-and-tools' - : systemChanged - ? 'system' - : 'tools', - ...(previous === undefined ? {} : { previous }), - } -} - -/** Project ordinary and compaction provider calls into one chronological request stream. */ -function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] { - const requests: RequestView[] = [] - const ordinaryByStep = new Map() - const lastStepByTurn = new Map() - let activeStep: string | undefined - let activePrompt: ConversationPromptSnapshot | undefined - let activeCompaction: number | undefined - - const updateAssistant = ( - index: number | undefined, - change: Partial>, - ): void => { - if (index === undefined) return - const request = requests[index] - if (request?.purpose === 'assistant') requests[index] = { ...request, ...change } - } - const updateCompaction = ( - index: number | undefined, - change: Partial>, - ): void => { - if (index === undefined) return - const request = requests[index] - if (request?.purpose === 'compaction') requests[index] = { ...request, ...change } - } - - for (const sourceEvent of events) { - if (sourceEvent.type === 'step/start') { - const { turn, step } = sourceEvent.data - const key = requestKey(turn, step) - ordinaryByStep.set(key, requests.length) - lastStepByTurn.set(turn, key) - requests.push({ - purpose: 'assistant', - startSeq: sourceEvent.seq, - turn, - step, - startedAt: sourceEvent.time, - completedAt: null, - status: 'running', - ...(activePrompt === undefined - ? {} - : { prompt: activePrompt, requestConfig: activePrompt.config }), - }) - activeStep = key - continue - } - if (sourceEvent.type === 'request/header') { - const tools: unknown = sourceEvent.data.header.tools - const prompt: ConversationPromptSnapshot = { - config: sourceEvent.data.header.config, - system: sourceEvent.data.header.system ?? '', - tools: Array.isArray(tools) ? tools as ToolSchema[] : [], - } - const change = promptChange(activePrompt, prompt, sourceEvent) - activePrompt = prompt - updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), { - prompt, - requestConfig: prompt.config, - ...(change === undefined ? {} : { promptChange: change }), - }) - continue - } - if ( - sourceEvent.type === 'assistant/chunk' - && sourceEvent.data.chunk.type === 'usage' - ) { - const index = ordinaryByStep.get( - requestKey(sourceEvent.data.turn, sourceEvent.data.step), - ) - const request = index === undefined ? undefined : requests[index] - updateAssistant(index, { - usage: addTokenUsage( - request?.purpose === 'assistant' ? request.usage : undefined, - sourceEvent.data.chunk.usage, - ), - }) - continue - } - if (sourceEvent.type === 'assistant/message') { - const index = ordinaryByStep.get( - requestKey(sourceEvent.data.turn, sourceEvent.data.step), - ) - const request = index === undefined ? undefined : requests[index] - updateAssistant(index, { - completedAt: sourceEvent.time, - status: 'complete', - resultSeq: sourceEvent.seq, - provenance: { - provider: sourceEvent.data.message.source.provider, - model: sourceEvent.data.message.source.model, - }, - ...(request?.purpose === 'assistant' - && request.usage !== undefined - || sourceEvent.data.usage === undefined - ? {} - : { usage: sourceEvent.data.usage }), - }) - continue - } - if (sourceEvent.type === 'step/end') { - const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step) - const index = ordinaryByStep.get(key) - const request = index === undefined ? undefined : requests[index] - if (request?.purpose === 'assistant' && request.status === 'running') { - updateAssistant(index, { - completedAt: sourceEvent.time, - status: 'error', - }) - } - if (activeStep === key) activeStep = undefined - continue - } - if (sourceEvent.type === 'llm/retry') { - const data = sourceEvent.data - updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), { - status: 'error', - error: displayFailureMessage(data.failure), - retry: data.retry, - ...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}, - retryDelayMs: data.delayMs, - }) - continue - } - if (sourceEvent.type === 'turn/end') { - const lastStep = lastStepByTurn.get(sourceEvent.data.turn) - if (sourceEvent.data.reason.kind === 'error') { - updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), { - status: 'error', - error: displayFailureMessage(sourceEvent.data.reason.error), - }) - } - lastStepByTurn.delete(sourceEvent.data.turn) - continue - } - - if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) { - updateCompaction(activeCompaction, { - completedAt: sourceEvent.time, - status: 'error', - error: 'Compaction was interrupted before completion.', - }) - activeCompaction = undefined - continue - } - if (sourceEvent.type === 'compact/start') { - activeCompaction = requests.length - requests.push({ - purpose: 'compaction', - startSeq: sourceEvent.seq, - turn: sourceEvent.data.turn, - step: 0, - startedAt: sourceEvent.time, - completedAt: null, - status: 'running', - }) - continue - } - if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) { - const data = sourceEvent.data - updateCompaction(activeCompaction, { - resultSeq: sourceEvent.seq, - summary: data.summary, - ...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }), - provenance: { - provider: data.provider, - model: data.model, - }, - requestConfig: { - provider: data.provider, - model: data.model, - purpose: 'compaction', - ...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }), - }, - ...(data.usage === undefined ? {} : { usage: data.usage }), - }) - continue - } - if ( - sourceEvent.type === 'user/message' - && activeCompaction !== undefined - && isCompactionSource(sourceEvent.data.source) - ) { - updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq }) - continue - } - if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue - updateCompaction(activeCompaction, { - completedAt: sourceEvent.time, - status: sourceEvent.data.error === undefined ? 'complete' : 'error', - ...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }), - }) - activeCompaction = undefined - } - - return requests.sort((left, right) => left.startSeq - right.startSeq) -} - -function isCompactionSource(source: unknown): boolean { - return typeof source === 'object' - && source !== null - && 'kind' in source - && source.kind === 'plugin' - && 'plugin' in source - && source.plugin === 'compact' -} diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts deleted file mode 100644 index 2f15bc9c92..0000000000 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { describe, expect, it } from 'vitest' -import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' -import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts' -import { inspectRequests } from '../src/client/sessions/request-inspection.ts' -import { ev } from './event-script.ts' - -const at = (seq: number, event: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent - -describe('projectConversationHistory', () => { - it('names an injected context node from its durable source, like the live adapter', () => { - // The fold declares its own node mapping (jscpd:ignore in the source), so - // the source projection is pinned on both sides independently. - const injected = at(0, { - type: 'user/message', - surfaceOp: 'append', - data: createUserMessage({ - content: [{ type: 'text', text: '' }], - // A plugin source, because the client program does not see the host - // packages that merge richer source kinds; those arms are pinned in - // context-provenance.spec.ts. - source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' }, - }), - }) - const { contexts } = projectConversationHistory([{ event: injected }]) - expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{ - kind: 'context', - seq: 0, - provenance: { role: 'inject', label: 'dsh-tool-skill' }, - form: 'catalog', - }]) - }) - - it('projects next-step human input as durable steering', () => { - const steering = createUserMessage({ - content: [{ type: 'text', text: 'change course' }], - source: { kind: 'user' }, - }) - const events = [ - at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } }), - at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(2, { type: 'user/message', surfaceOp: 'append', data: steering }), - ] - const projection = projectConversationHistory(events.map(event => ({ event }))) - expect(projection.eventNodes).toMatchObject([{ - kind: 'steering', messageId: steering.id, seq: 2, - }]) - }) - - it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { - const baseSeq = 400_000 - const events = [ - ev.user(baseSeq, 'loaded tail'), - at(baseSeq + 1, { - type: 'assistant/message', - surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, - sourceEventSeqs: [baseSeq], - data: { - turn: 80, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'tail summary' }], - source: { kind: 'model', provider: 'fake', model: 'fake' }, - }), - }, - }), - ] - - const projection = projectConversationHistory(events.map(event => ({ event }))) - expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1]) - expect(projection.contexts.map(context => ({ - originSeq: context.originSeq, - nodes: context.nodes.map(node => node.seq), - }))).toEqual([ - { originSeq: undefined, nodes: [baseSeq] }, - { originSeq: baseSeq + 1, nodes: [baseSeq + 1] }, - ]) - }) - - it('projects frozen surface generations without widening the core live surface', () => { - const events = [ - ev.user(0, 'a'), - ev.user(1, 'b'), - at(2, { - type: 'assistant/message', - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], - data: { - turn: 1, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'summary' }], - source: { kind: 'model', provider: 'fake', model: 'fake' }, - }), - }, - }), - at(3, { - type: 'assistant/message', - surfaceOp: { op: 'replace', start: 2, end: 1 }, - sourceEventSeqs: [2, 1], - data: { - turn: 1, - step: 2, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'summary 2' }], - source: { kind: 'model', provider: 'fake', model: 'fake' }, - }), - }, - }), - ] - - expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({ - id: context.id, - parentId: context.parentId, - originSeq: context.originSeq, - nodes: context.nodes.map(node => node.seq), - }))).toEqual([ - { id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] }, - { id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] }, - { id: 2, parentId: 1, originSeq: 3, nodes: [3] }, - ]) - }) - - it('projects assistant timing and the active request header from history', () => { - const projection = projectConversationHistory([ - ev.stepStart(0, 1, 2), - at(1, { type: 'request/header', data: { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'first' }, - tools: [], - }, - } }), - ev.chunkStart(2, 1, 2), - ev.chunkText(3, 1, 'token', 2), - ev.assistant(4, 1, 'done', 2), - ev.stepStart(5, 2, 1), - ev.chunkText(6, 2, 'next', 1), - ev.assistant(7, 2, 'next done', 1), - ].map(event => ({ event }))) - - expect(projection.eventNodes[0]).toMatchObject({ - kind: 'assistant', - timing: { - stepStartTime: 1_700_000_000_000, - firstTokenTime: 1_700_000_000_003, - completedTime: 1_700_000_000_004, - }, - requestConfig: { provider: 'fake', model: 'first' }, - }) - - expect(projection.eventNodes.at(-1)).toMatchObject({ - timing: { - stepStartTime: 1_700_000_000_005, - firstTokenTime: 1_700_000_000_006, - completedTime: 1_700_000_000_007, - }, - requestConfig: { provider: 'fake', model: 'first' }, - }) - }) - - it('projects nested dispatches onto settled and interrupted history calls', () => { - const projection = projectConversationHistory([ - ev.turnStart(0, 1), - ev.toolCall(1, 1, 'settled', 'run_code', '{}'), - ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }), - ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }), - ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'), - ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'), - ev.toolResult(6, 1, 'settled', 'done'), - ev.turnEnd(7, 1), - ev.turnStart(8, 2), - ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'), - ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }), - ev.turnEnd(11, 2, 'aborted'), - ].map(event => ({ event }))) - - const settled = { - callId: 'settled', - subCalls: [{ - callId: 'settled:code:1', - subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }], - }], - } - expect(projection.eventNodes).toMatchObject([settled]) - expect(projection.contexts[0]?.nodes).toMatchObject([settled]) - expect(projection.interruptedNodes).toMatchObject([{ - callId: 'interrupted', - subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }], - }]) - }) - - it('drops completed token payloads without changing inspection projections', () => { - const events = [ - ev.user(0, 'before'), - ev.stepStart(1, 1, 0), - ev.chunkStart(2, 1), - ev.chunkText(3, 1, ''), - ev.chunkText(4, 1, 'first'), - ev.chunkText(5, 1, ' discarded'), - at(6, { type: 'assistant/chunk', data: { - turn: 1, - step: 0, - chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }, - } }), - ev.assistant(7, 1, 'first discarded'), - ev.compactSummary(8, 'summary', 0, 7), - ev.compactCheckpoint(9, 8, 0, 7), - ev.stepStart(10, 2, 0), - ev.chunkStart(11, 2), - ev.chunkText(12, 2, 'interrupted'), - ev.turnEnd(13, 2, 'aborted'), - ] - const raw = events.map(event => ({ event })) - const compacted = compactHistoryInspectionEntries(raw) - - expect(compacted.map(entry => entry.event.seq)).toEqual([ - 0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13, - ]) - expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw)) - expect(inspectRequests(compacted)).toEqual(inspectRequests(raw)) - }) -}) diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts deleted file mode 100644 index 031c2f4b45..0000000000 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { inspectRequests } from '../src/client/sessions/request-inspection.ts' - -const at = (seq: number, type: string, data: unknown): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent - -const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] => - events.map(event => ({ event })) - -describe('inspectRequests', () => { - it('projects ordinary and compaction calls into one chronological request stream', () => { - const events = [ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - system: 'system', - tools: [{ - name: 'read', - description: 'Read a file.', - parameters: { type: 'object' }, - }], - }, - }), - at(2, 'tool/call', { - turn: 1, - step: 1, - callId: 'call-1', - name: 'read', - arguments: '{}', - }), - at(3, 'assistant/message', { - turn: 1, - step: 1, - message: createAssistantMessage({ - content: [{ type: 'text', text: 'done' }], - source: { provider: 'fake', model: 'model' }, - }), - usage: { inputTokens: 5, outputTokens: 2 }, - }), - at(4, 'step/end', { turn: 1, step: 1 }), - at(5, 'compact/start', { turn: 1 }), - at(6, 'compact/summary', { - summary: [{ type: 'text', text: 'summary' }], - rawOutput: [ - { type: 'reasoning', text: 'thought' }, - { type: 'text', text: 'summary' }, - ], - provider: 'fake', - model: 'compact-model', - usage: { inputTokens: 8, outputTokens: 3 }, - }), - at(7, 'user/message', createUserMessage({ - content: [{ type: 'text', text: 'checkpoint' }], - source: { kind: 'plugin', plugin: 'compact' }, - })), - at(8, 'compact/end', { turn: 1 }), - ] - const snapshot = inspectRequests(entriesOf(events)) - expect(snapshot.requests).toMatchObject([ - { - purpose: 'assistant', - startSeq: 0, - resultSeq: 3, - status: 'complete', - prompt: { - config: { provider: 'fake', model: 'model' }, - system: 'system', - }, - promptChange: { seq: 1, kind: 'initial' }, - }, - { - purpose: 'compaction', - startSeq: 5, - resultSeq: 6, - replacementSeq: 7, - status: 'complete', - summary: [{ type: 'text', text: 'summary' }], - }, - ]) - expect(snapshot.callSchemas.get('call-1')?.name).toBe('read') - }) - - it('does not promote a truncated resume or change header to the initial prompt', () => { - for (const reason of ['resume', 'change'] as const) { - const snapshot = inspectRequests(entriesOf([ - at(10, 'step/start', { turn: 3, step: 1 }), - at(11, 'request/header', { - reason, - header: { - config: { provider: 'fake', model: 'model' }, - system: 'tail-window prompt', - }, - }), - ])) - - expect(snapshot.requests[0]).toMatchObject({ - purpose: 'assistant', - prompt: { system: 'tail-window prompt' }, - }) - expect(snapshot.requests[0]).not.toHaveProperty('promptChange') - } - }) - - it('classifies a prompt change once the preceding header is loaded', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - system: 'before', - }, - }), - at(2, 'step/start', { turn: 1, step: 2 }), - at(3, 'request/header', { - reason: 'change', - header: { - config: { provider: 'fake', model: 'model' }, - system: 'after', - }, - }), - ])) - - expect(snapshot.requests[1]).toMatchObject({ - promptChange: { - seq: 3, - kind: 'system', - previous: { system: 'before' }, - }, - }) - }) - - it('preserves a standalone compaction owner without widening assistant turns', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'compact/start', { turn: null }), - at(1, 'compact/summary', { - summary: [{ type: 'text', text: 'standalone summary' }], - provider: 'fake', - model: 'compact-model', - }), - at(2, 'compact/end', { turn: null }), - at(3, 'step/start', { turn: 2, step: 1 }), - ])) - - const [compaction, assistant] = snapshot.requests - expect(compaction).toMatchObject({ - purpose: 'compaction', - turn: null, - step: 0, - status: 'complete', - }) - expect(assistant).toMatchObject({ - purpose: 'assistant', - turn: 2, - step: 1, - status: 'running', - }) - if (assistant?.purpose === 'assistant') { - const turn: number = assistant.turn - expect(turn).toBe(2) - } - }) - - it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'compact/start', { turn: null }), - at(1, 'session/end-seed', {}), - at(2, 'compact/start', { turn: null }), - at(3, 'compact/summary', { - summary: [{ type: 'text', text: 'replacement summary' }], - provider: 'fake', - model: 'compact-model', - }), - at(4, 'compact/end', { turn: null }), - ])) - - expect(snapshot.requests).toMatchObject([ - { - purpose: 'compaction', - startSeq: 0, - status: 'error', - completedAt: 1_700_000_000_001, - error: 'Compaction was interrupted before completion.', - }, - { - purpose: 'compaction', - startSeq: 2, - status: 'complete', - completedAt: 1_700_000_000_004, - summary: [{ type: 'text', text: 'replacement summary' }], - }, - ]) - }) - - it('captures schemas for nested tool dispatches from the active request header', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - tools: [{ - name: 'read', - description: 'Read a file.', - parameters: { type: 'object' }, - }], - }, - }), - at(1, 'tool/code-dispatch-start', { - parentCallId: 'parent', - subCallId: 'nested', - name: 'read', - arguments: {}, - }), - ])) - - expect(snapshot.callSchemas.get('nested')?.name).toBe('read') - }) - - it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => { - const chunkUsage = { inputTokens: 21, outputTokens: 3 } - const retryUsage = { - inputTokens: 5, - outputTokens: 2, - cacheReadTokens: 8, - reasoningTokens: 1, - } - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'usage', usage: chunkUsage }, - }), - at(2, 'llm/retry', { - turn: 1, - step: 1, - retry: 1, - maxRetries: 2, - delayMs: 100, - failure: { message: 'rate limited' }, - }), - at(3, 'assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'usage', usage: retryUsage }, - }), - at(4, 'assistant/message', { - turn: 1, - step: 1, - message: createAssistantMessage({ - content: [{ type: 'text', text: 'recovered' }], - source: { provider: 'fake', model: 'model' }, - }), - usage: { inputTokens: 1, outputTokens: 1 }, - }), - ])) - - expect(snapshot.requests[0]).toMatchObject({ - status: 'complete', - usage: { - inputTokens: 26, - outputTokens: 5, - cacheReadTokens: 8, - reasoningTokens: 1, - }, - }) - }) - - it('keeps provider credential fragments out of projected request errors', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'turn/end', { - turn: 1, reason: { kind: 'error', error: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, - }, - }), - at(2, 'step/start', { turn: 2, step: 1 }), - at(3, 'turn/end', { - turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } }, - }), - ])) - - expect(snapshot.requests).toMatchObject([ - { status: 'error', error: 'API key is invalid' }, - { status: 'error', error: 'plugin exploded' }, - ]) - }) - - it('treats a scrubbed durable-fixture tool catalog as unavailable', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - tools: '{{tools}}', - }, - }), - at(2, 'tool/call', { - turn: 1, - step: 1, - callId: 'call-1', - name: 'read', - arguments: '{}', - }), - ])) - - expect(snapshot.callSchemas).toEqual(new Map()) - const [request] = snapshot.requests - expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([]) - }) -}) diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts deleted file mode 100644 index 2bc0aa87af..0000000000 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import { SessionHistorySource } from '../src/client/session-history/source.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' -import { entries, ev, plainTurn } from './event-script.ts' - -const SID = 'history-s1' as SessionId - -afterEach(() => { - vi.unstubAllGlobals() -}) - -function histResponse(events: SessionEvent[], hasMore = false) { - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) -} - -describe('SessionHistorySource', () => { - it('loads the tail first and prepends older pages on demand', async () => { - const pages = [ - plainTurn(0, 0, '最早问', '最早答'), - plainTurn(6, 1, '中间问', '中间答'), - plainTurn(12, 2, '最新问', '最新答'), - ] - const api = new FakeApiClient() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) - if (payload.beforeSeq === 12) return histResponse(pages[1]!, true) - return histResponse(pages[0]!, false) - } - const source = new SessionHistorySource(SID, api) - - await source.loadTail() - - expect(api.callsOf('session.history')).toHaveLength(1) - expect(source.getSnapshot().hasMore).toBe(true) - expect(source.getSnapshot().baseSeq).toBe(12) - expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) - .toEqual([13, 15]) - - expect(await source.loadOlder()).toBe(true) - expect(await source.loadOlder()).toBe(true) - expect(await source.loadOlder()).toBe(false) - - expect(api.callsOf('session.history')).toHaveLength(3) - expect(source.getSnapshot().hasMore).toBe(false) - expect(source.getSnapshot().baseSeq).toBe(0) - expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) - .toEqual([1, 3, 7, 9, 13, 15]) - }) - - it('pins a lazy inspection to the entries in its source snapshot', async () => { - const api = new FakeApiClient() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - const source = new SessionHistorySource(SID, api) - await source.loadTail() - const before = source.getSnapshot() - - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event: ev.user(6, 'later'), - }) - - expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3]) - expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) - .toEqual([1, 3, 6]) - }) - - it('publishes multiple assistant chunks once per browser frame', async () => { - const api = new FakeApiClient() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - const source = new SessionHistorySource(SID, api) - await source.loadTail() - const frames: FrameRequestCallback[] = [] - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frames.push(callback) - return frames.length - }) - let notifications = 0 - const unsubscribe = source.subscribe(() => { notifications++ }) - const before = source.getSnapshot().inspection - const finalizedNodes = before.eventNodes - const requests = before.requests - const contexts = before.contexts - - for (const event of [ - ev.chunkStart(6, 1), - ev.chunkText(7, 1, 'stream '), - ev.chunkText(8, 1, 'content'), - ]) { - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event, - }) - } - - expect(frames).toHaveLength(1) - expect(notifications).toBe(0) - frames[0]?.(0) - await Promise.resolve() - - expect(notifications).toBe(1) - const streamed = source.getSnapshot().inspection - expect(streamed.eventNodes).toBe(finalizedNodes) - expect(streamed.requests).toBe(requests) - expect(streamed.contexts).toBe(contexts) - expect(streamed.partial?.blocks).toEqual([ - { kind: 'text', text: 'stream content' }, - ]) - - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event: ev.chunkText(9, 1, ' then final'), - }) - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event: ev.assistant(10, 1, 'stream content then final'), - }) - await Promise.resolve() - - expect(notifications).toBe(2) - const finalized = source.getSnapshot().inspection - expect(finalized.eventNodes).not.toBe(finalizedNodes) - expect(finalized.partial).toBeNull() - frames[1]?.(0) - await Promise.resolve() - expect(notifications).toBe(2) - unsubscribe() - }) - - it('stops loading when an older page fails to advance', async () => { - const api = new FakeApiClient() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(6, 1, '新问', '新答'), true) - : Promise.resolve(err({ - code: 'internal', - message: 'page unavailable', - details: {}, - })) - const source = new SessionHistorySource(SID, api) - - await source.loadTail() - expect(await source.loadOlder()).toBe(false) - - expect(api.callsOf('session.history')).toHaveLength(2) - expect(source.getSnapshot().hasMore).toBe(true) - }) - - it('finishes an already started older page after consumer cancellation', async () => { - const middle = deferred>>() - const olderStarted = deferred() - const api = new FakeApiClient() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) { - return histResponse(plainTurn(12, 2, '最新问', '最新答'), true) - } - olderStarted.resolve(undefined) - return middle.promise - } - const source = new SessionHistorySource(SID, api) - const controller = new AbortController() - await source.loadTail(controller.signal) - const complete = source.loadOlder(controller.signal) - await olderStarted.promise - controller.abort() - middle.resolve(ok({ - events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[], - hasMore: true, - })) - - expect(await complete).toBe(true) - - expect(api.callsOf('session.history')).toHaveLength(2) - expect(source.getSnapshot().hasMore).toBe(true) - }) -}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 79419be5ed..4a26b80c28 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -132,7 +132,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface', modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface', remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface', - sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface', slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface', slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface', From a342b329e358fdb23274fb6c086d5180e8aed9a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:05:23 +0800 Subject: [PATCH 04/17] docs(client): document trajectory conversation assembly --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 21 +++++----- ...09-client-conversation-node-assembly.zh.md | 21 +++++----- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 10 ++--- ...6-07-27-trajectory-inspection-ledger.zh.md | 10 ++--- ...b-context-source-and-steer-marks.i18n.yaml | 4 +- ...8-04-web-context-source-and-steer-marks.md | 4 +- ...4-web-context-source-and-steer-marks.zh.md | 4 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 11 +++--- packages/client/runtime/README.zh.md | 11 +++--- packages/client/runtime/package.json | 1 - .../src/client/contract/conversation.ts | 2 +- .../client/sessions/conversation-assembler.ts | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 3 +- .../src/client/turn-deliverables.ts | 2 - .../tests/produced-files.spec.tsx | 2 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../client/trajectory-assistant-definition.ts | 32 +++++++++------- .../trajectory-compaction-definition.ts | 38 ++++++++++--------- .../client/trajectory-definition-common.ts | 16 +++++++- .../client/trajectory-message-definitions.ts | 32 +++++++++------- .../trajectory-request-header-definition.ts | 12 ++++-- .../src/client/trajectory-snapshot-builder.ts | 18 +++++---- .../src/client/trajectory-tool-definition.ts | 6 ++- .../client/ui-trajectory/tests/views.spec.tsx | 7 ++-- pnpm-lock.yaml | 3 -- 33 files changed, 161 insertions(+), 139 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index b76951347b..1dec922826 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 16a39539064644e5467f701789a7e2ef1f7ff172 -2026-08-09-client-conversation-node-assembly.zh.md: 0e0fbdf8f3320393022528e6e3fe2cf0d492a1d3 +2026-08-09-client-conversation-node-assembly.md: 1d5fd20bfa8c3b370f736937d54a668ca7f19ca3 +2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 16a3953906..1d5fd20bfa 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -148,7 +148,7 @@ The Assembler verifies `node.key === context.key` and `node.target === target`. `current` lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry and Turn Error suppression use it to avoid illegal Node withdrawal. -A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory remains on its independent `session-history` fold until it gains a registered target. +A Definition owns at most one view target; state-only Definitions omit both `target` and `buildViewNode()`. Chat and Trajectory register separate business Definitions even when they recognize the same durable Event family, while the shared Assembler supplies the same matching, replay, Location, and publication mechanics to both targets. #### No generic `end()` @@ -328,7 +328,7 @@ When business logic deliberately changes a materialized Node to hidden, it leave The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot. -Trajectory has no registered target and does not consume the Chat Builder's legacy slice. Its activated `SessionHistoryInspection` keeps an independent history fold, while the ordinary Session snapshot no longer runs a second transcript fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; a future Trajectory migration does not change the Event Definition, Context, Reader, or Location contracts. +Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts. ## Runtime and render path @@ -339,20 +339,17 @@ Session Event window -> Context matches + State + Location -> Definition.buildLocationData(step -> turn) -> StepLocation.data / TurnLocation.data - -> Definition.buildViewNode(target = chat) - -> ChatSnapshotBuilder - -> order[] + keyed Node store + Location index + timeline - -> ChatView - -> ChatNodeSeat(key) - -> conversation.chat.node(entryKey = node.kind, hookContext = key) - -> slot-level useTurnData(businessKey) + -> Definition.buildViewNode() for its declared target + -> target View Builder + -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat + -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table ``` ## Verification Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders. -Conversation tests cover every built-in Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. +Conversation tests cover every built-in Chat Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. Trajectory tests cover its independently registered Message, Assistant, Tool, Compaction, Request-header, and boundary Definitions together with the preserved stage-oriented view model. Slot type/runtime tests pin required parent-provided common inject, the `hookContext` type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory. @@ -382,7 +379,7 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping- **Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation. -**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory's independent history fold remains until it registers its own Builder. +**Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts. **Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts. @@ -406,4 +403,4 @@ Steps and Turns become stable homes for cross-business aggregates. Turn Tail and The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal. -`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, Trajectory still owns an independent history fold, and built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. +`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 0e0fbdf8f3..6f0acc4489 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -148,7 +148,7 @@ Assembler 校验 Node `key === context.key` 且 Node `target === target`。业 `current` 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry 和 Turn Error suppression 使用它避免非法的 Node 撤回。 -Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder;在拥有注册 target 之前,Trajectory 继续使用独立的 `session-history` fold。 +一个 Definition 最多拥有一个 view target;仅维护状态的 Definition 同时省略 `target` 与 `buildViewNode()`。即使 Chat 与 Trajectory 识别同一持久 Event 族,它们也分别注册自己的业务 Definition;共享 Assembler 则为两个 target 提供相同的匹配、replay、Location 与发布机制。 #### 不提供通用 `end()` @@ -328,7 +328,7 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data,`ui-tool` 再按 Tool name keyed slot 分发具体表现。 -Trajectory 尚未注册 target,也不消费 Chat Builder 的 legacy slice。它已激活的 `SessionHistoryInspection` 继续维护独立 history fold,而普通 Session snapshot 不再运行第二套 transcript fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;未来迁移 Trajectory 不改变 Event Definition、Context、Reader 或 Location 契约。 +Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。 ## Runtime and render path @@ -339,20 +339,17 @@ Session Event window -> Context matches + State + Location -> Definition.buildLocationData(step -> turn) -> StepLocation.data / TurnLocation.data - -> Definition.buildViewNode(target = chat) - -> ChatSnapshotBuilder - -> order[] + keyed Node store + Location index + timeline - -> ChatView - -> ChatNodeSeat(key) - -> conversation.chat.node(entryKey = node.kind, hookContext = key) - -> slot-level useTurnData(businessKey) + -> Definition.buildViewNode() for its declared target + -> target View Builder + -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat + -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table ``` ## Verification Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。 -Conversation tests 覆盖全部内建 Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。 +Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。Trajectory tests 则覆盖它独立注册的 Message、Assistant、Tool、Compaction、Request-header 与 boundary Definition,以及继续保留的 stage-oriented view model。 Slot type/runtime tests 固定父注册必须提供声明的 common inject、`hookContext` 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。 @@ -382,7 +379,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 **增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。 -**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在注册自己的 Builder 之前继续使用独立 history fold。 +**在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期契约。 **在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。 @@ -406,4 +403,4 @@ Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverable 代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。 -`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 继续拥有独立 history fold,内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 +`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index b73a552168..1e7284378c 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: a905e65942365c17b7513028b275288c82428221 -2026-07-27-trajectory-inspection-ledger.zh.md: a8dcfa97a89f3adc6ab540f3d6cc5020cbefb53f +2026-07-27-trajectory-inspection-ledger.md: c09213d35e984ca717d283d45259f61d413407a3 +2026-07-27-trajectory-inspection-ledger.zh.md: 9d2c615dea5b0774201118a0b0abb9862a228690 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index a905e65942..c09213d35e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -16,16 +16,16 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. -- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory opens the source's tail while mounted and requests one older page when the user reaches the loaded range's top, then lazily derives event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. +- Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. Its Definitions and target builder derive event order, context lineage, schema index, and Requests without making those structures part of the Chat snapshot. - Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. The raw window base sequence detects a prepend even when a page adds no surface-visible node. +- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. -- Token streaming reuses the finalized history inspection, layout, Request numbering, Overview projection, and search results. A frame appends only the current partial Assistant cells and searches that partial when a query is active; text and reasoning deltas do not re-fold or rescan the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. Before those rebuilds, the inspection ledger drops completed-step token payloads that no projection reads while retaining the first visible token for timing, every usage chunk for accounting, and every chunk from unfinished or interrupted steps; the independent history source retains the raw entries. -- History folding rebases only the loaded surface events into a compact contiguous input for the canonical surface manager, then maps its nodes back to absolute session sequences. Structural events therefore retain canonical replacement validation without replaying token chunks or materializing synthetic events for unloaded sequences. +- Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. +- Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. @@ -53,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, incremental partial projection removes loaded-history length from ordinary token-frame work, and completed-step chunk compaction makes structural rebuilds proportional to inspection-relevant entries rather than the raw token count. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport work, virtualization bounds mounted row elements, exact-ID dispatch avoids re-folding unrelated business Contexts, and animation-frame publication caps streaming snapshot frequency. The retained stage-oriented target builder may still perform work proportional to the loaded materialized Nodes for a publication; this migration does not add a stronger Trajectory-specific complexity guarantee. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index a8dcfa97a8..9d2c615dea 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -16,16 +16,16 @@ Status: implemented - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- 客户端运行时提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费方承担这些结构。 +- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、上下文谱系、schema 索引和请求,无须把这些结构放进 Chat snapshot。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 -- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。 +- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 -- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。在这些投影重建前,检查记录表会丢弃已完成步骤中没有任何投影读取的 token 载荷,但会保留首个可见 token 用于计时、保留所有用量分片用于核算,并保留未完成或中断步骤的所有分片;独立历史数据源仍保留原始条目。 -- 历史折叠只把已加载的 surface 事件重新编号为紧凑连续的输入并交给规范 surface manager,再将其节点映射回会话绝对序号。因此,结构事件会保留规范的替换校验,而无需重放 token 分片,也不会为未加载的序号实体化合成事件。 +- token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 +- 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 @@ -53,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Context,animation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index 86b48310e7..362aef352f 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 -2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b +2026-08-04-web-context-source-and-steer-marks.md: d4fee3ee25aceaf05106d6bd1bdb73e7c51c3f78 +2026-08-04-web-context-source-and-steer-marks.zh.md: 8e0ffa6c15ea7506e1aaed9f0b142925727856aa diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 01bdca873a..d4fee3ee25 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -14,13 +14,13 @@ The distinctions are already durable. Every producer must supply a merge-extensi The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. -`TranscriptAdapter` and the history fold attach a `provenance` view containing the producer role and label to every `ContextMessageNode`; `contextProvenance()` computes it from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [archived disclosure decision](../../archived/feature/2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). +The Chat Message Definition attaches a `provenance` view containing the producer role and label to every `ContextMessageNode`; `contextProvenance()` computes it from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [archived disclosure decision](../../archived/feature/2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). **The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. `recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. -`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. +`MessageItem` captions durable and pending steering bubbles with `插话`. The Chat Inbox and Message Definitions replay durable `agent/inbox/spliced` events and project a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index b6a9cc5692..8e0ffa6c15 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -14,13 +14,13 @@ Status: implemented transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 -`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份包含生产者角色和名称的 `provenance` 视图;`contextProvenance()` 仅依据持久来源计算该视图。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[已归档的展开项决策](../../archived/feature/2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 +Chat Message Definition 为每个 `ContextMessageNode` 附加一份包含生产者角色和名称的 `provenance` 视图;`contextProvenance()` 仅依据持久来源计算该视图。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[已归档的展开项决策](../../archived/feature/2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 `recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 -`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 +Chat Inbox 与 Message Definition 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。`MessageItem` 为这种持久消息与待处理 steering 气泡加上 `插话` 标注。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index cc081c72c4..7f86295344 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: e572929ae6762da6adc2e77e1dba19361beaf670 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 99f86f40ba006c4024f367b73ce52f8679b8d2fd +2026-07-24-web-gui-browser-e2e-lane.md: 6e52d96a8adb5486e8666d65a3425bf5a0aad4a9 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 7598a8edc530a34261799e57ce953edafe44e70e diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index e572929ae6..6e52d96a8a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -88,7 +88,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. - **Drag session reorder**: `workspace.insertSessionBefore` has no browser scenario; it needs two sessions materialized in one workspace plus synthesized HTML5 drag events. Add it when that surface changes or regresses. The inert session Rename/Fork/Delete and workspace Delete menu rows get scenarios when they gain behavior. -- **Long-history Chat-to-Trajectory Inspect**: the independent inspection source exhausts history after the view opens, while the selected record is addressed by a derived table index that can move as older pages prepend. Short-history Inspect remains covered; the long-history interaction contract excludes this handoff until selection has a stable semantic identity. +- **Long-history Chat-to-Trajectory Inspect**: both views share Session paging, while the selected Trajectory record is addressed by a derived table index that can move as older pages prepend. Short-history Inspect remains covered; the long-history interaction contract excludes this handoff until selection has a stable semantic identity. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 99f86f40ba..7598a8edc5 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -88,7 +88,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 - **拖拽会话重排**:`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。 -- **长历史 Chat 到 Trajectory 的 Inspect**:独立的检查数据源会在视图打开后穷尽历史,而所选记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互约定不包含这项交接。 +- **长历史 Chat 到 Trajectory 的 Inspect**:两个视图共用 Session 分页,而所选 Trajectory 记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互约定不包含这项交接。 ## 后果 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 7dc91083c9..9743f813da 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412 -README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8 +README.md: d84cd793c34242759ad04edf0debb91558ec3dfc +README.zh.md: e7a74c454f24fcec5e797427c21222b1dc258b44 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1ec6cc38ae..d84cd793c3 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,10 +2,9 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. `bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. - ## Slot declaration injection `ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. @@ -42,17 +41,17 @@ Each `Session` gives its contiguous event window to a `ConversationNodeAssembler Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path. -`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold. The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). -## Request inspection +## Trajectory request data -`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. +Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. ## Code Mode child-call tree -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract. +Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target. ## Session title projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6602152790..e7a74c454f 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,10 +2,9 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 `bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 - ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 @@ -42,17 +41,17 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 -`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder;它保留现有的 stage-oriented view model,既不消费 Chat 兼容字段,也不运行另一套 history fold。 Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 -## 请求检查 +## Trajectory 请求数据 -`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 +Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 ## Code Mode 子调用树 -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。 +每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。 ## Session 标题投影 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 749e564ef5..86d51cd7a3 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -46,7 +46,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 7119980839..26e7e43c67 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -116,7 +116,7 @@ export interface ConversationViewSnapshotMap {} /** Stable reader over the latest snapshot of every registered view target. */ export interface ConversationViewSnapshotStore { /** @param target - registered view target. @returns its current snapshot. */ - get( + get>( target: Target, ): ConversationViewSnapshotMap[Target] | undefined } diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index ee8e6b0eae..85c59a4053 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -324,7 +324,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.views.get(target)?.snapshot } - get( + get>( target: Target, ): ConversationViewSnapshotMap[Target] | undefined { return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index fd306eb132..084e4ffa3a 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -49,7 +49,8 @@ type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], + turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 0061400e7a..e63bca2e63 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -97,7 +97,6 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ /** Turn-local successful mutation accumulator; it publishes no view Node. */ export const deliverablesDefinition: ConversationNodeDefinition = { kind: 'deliverables', - target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } @@ -137,7 +136,6 @@ export const deliverablesDefinition: ConversationNodeDefinition null, } /** diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 48303f2e54..31289b38de 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,7 +73,7 @@ interface TimelineSnapshot { class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } - fallbackEntries(): readonly ConversationNodeDefinition[] { return [] } + fallbackEntry(): ConversationNodeDefinition | undefined { return undefined } } class TestViewDefinitions { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 7fecd92d74..17551b0cc8 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 5b8c0cd111c272007212fea0d2435c5fab2360ab -README.zh.md: 9aaa02ccd9d50b0f0b23e9a53ea9b1048d0e513f +README.md: 75bd9ddf452634460be01e1b89cd5a1a14a1593f +README.zh.md: b5cd53dd50e43b96e2e832c96cb7e93f859c1993 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5b8c0cd111..75bd9ddf45 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble context lineage and cancellation-frozen Assistant and Tool records from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9aaa02ccd9..b5cd53dd50 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装上下文谱系,以及因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 8f0d9ff430..d610f6979b 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -255,17 +255,17 @@ function assistantRequest( ...(state.retry === undefined ? {} : { - error: state.retry.message, - retry: state.retry.retry, - ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), - retryDelayMs: state.retry.delayMs, - }), + error: state.retry.message, + retry: state.retry.retry, + ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), + retryDelayMs: state.retry.delayMs, + }), ...(node === undefined || node.interrupted === true ? {} : { - resultSeq: node.seq, - ...(node.provenance === undefined ? {} : { provenance: node.provenance }), - }), + resultSeq: node.seq, + ...(node.provenance === undefined ? {} : { provenance: node.provenance }), + }), ...(state.usage === undefined ? {} : { usage: state.usage }), } } @@ -383,14 +383,18 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition = { buildViewNode: context => context.state === undefined ? null : trajectoryNode(context, context.state.seq, { - kind: 'turn-end', - turn: context.state.turn, - time: context.state.time, - ...(context.state.error === undefined ? {} : { error: context.state.error }), - }), + kind: 'turn-end', + turn: context.state.turn, + time: context.state.time, + ...(context.state.error === undefined ? {} : { error: context.state.error }), + }), } -/** Register the Trajectory Assistant lifecycle. */ +/** + * Register the Trajectory Assistant lifecycle. + * + * @param ctx - Plugin context receiving the Definitions. + */ export function registerTrajectoryAssistantDefinition(ctx: Context): void { ctx.conversationEvents.register(trajectoryAssistantDefinition) ctx.conversationEvents.register(trajectoryTurnEndDefinition) diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts index 65bab06059..de6d2af21b 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -61,18 +61,18 @@ function requestFromState( ...(summary?.type !== 'compact/summary' ? {} : { - resultSeq: summary.seq, - summary: summary.data.summary, - ...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }), - provenance: { provider: summary.data.provider, model: summary.data.model }, - requestConfig: { - provider: summary.data.provider, - model: summary.data.model, - purpose: 'compaction', - ...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }), - }, - ...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }), - }), + resultSeq: summary.seq, + summary: summary.data.summary, + ...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }), + provenance: { provider: summary.data.provider, model: summary.data.model }, + requestConfig: { + provider: summary.data.provider, + model: summary.data.model, + purpose: 'compaction', + ...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }), + }, + ...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }), + }), ...(checkpoint?.type === 'user/message' ? { replacementSeq: checkpoint.seq } : {}), } } @@ -126,13 +126,17 @@ const trajectorySessionEndDefinition: ConversationNodeDefinition context.state === undefined ? null : trajectoryNode(context, context.state.seq, { - kind: 'session-end', - seq: context.state.seq, - time: context.state.time, - }), + kind: 'session-end', + seq: context.state.seq, + time: context.state.time, + }), } -/** Register Trajectory compaction requests and session boundaries. */ +/** + * Register Trajectory compaction requests and session boundaries. + * + * @param ctx - Plugin context receiving the Definitions. + */ export function registerTrajectoryCompactionDefinitions(ctx: Context): void { ctx.conversationEvents.register(trajectoryCompactionDefinition) ctx.conversationEvents.register(trajectorySessionEndDefinition) diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts index d11034b9b9..8c9c7d6489 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -5,14 +5,26 @@ import type { TrajectoryContribution, TrajectoryConversationViewNode, } from './trajectory-contract.ts' -/** Resolve the best loaded Location for one target-local Context. */ +/** + * Resolve the best loaded Location for one target-local Context. + * + * @param context - Context whose loaded matches provide the Location. + * @returns The start Location, first-match Location, or unresolved fallback. + */ export function trajectoryContextLocation( context: ConversationNodeContext, ): ConversationLocation { return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } } -/** Wrap one contribution in the Engine-owned target envelope. */ +/** + * Wrap one contribution in the Engine-owned target envelope. + * + * @param context - Context that owns the contribution identity. + * @param anchorSeq - Sequence used to order the contribution. + * @param data - Trajectory-specific contribution payload. + * @returns The contribution wrapped as a Trajectory view node. + */ export function trajectoryNode( context: ConversationNodeContext, anchorSeq: number, diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index 5f6b203e28..a35b6080db 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -86,20 +86,20 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { ?.state.claimed.has(String(event.data.id)) === true return claimed ? { - kind: 'steering', - messageId: event.data.id, - seq: event.seq, - time: event.time, - content: event.data.content, - source: event.data.source, - } + kind: 'steering', + messageId: event.data.id, + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } : { - kind: 'user', - seq: event.seq, - time: event.time, - content: event.data.content, - source: event.data.source, - } + kind: 'user', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } }, update: context => context.state, buildViewNode: context => context.state === undefined @@ -107,7 +107,11 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), } -/** Register Trajectory-owned inbox classification and message records. */ +/** + * Register Trajectory-owned inbox classification and message records. + * + * @param ctx - Plugin context receiving the Definitions. + */ export function registerTrajectoryMessageDefinitions(ctx: Context): void { ctx.conversationEvents.register(trajectoryInboxDefinition) ctx.conversationEvents.register(trajectoryMessageDefinition) diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index a6ec4e4597..4d8a0c9006 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -65,12 +65,16 @@ const trajectoryRequestHeaderDefinition: ConversationNodeDefinition context.state === undefined ? null : trajectoryNode(context, context.state.seq, { - kind: 'request-header', - header: context.state, - }), + kind: 'request-header', + header: context.state, + }), } -/** Register Trajectory request-header facts. */ +/** + * Register Trajectory request-header facts. + * + * @param ctx - Plugin context receiving the Definition. + */ export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void { ctx.conversationEvents.register(trajectoryRequestHeaderDefinition) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 613cd1e750..717172c9d8 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -50,11 +50,11 @@ function applyHeader( return header === undefined ? request : { - ...request, - prompt: header.prompt, - requestConfig: header.prompt.config, - ...(header.change === undefined ? {} : { promptChange: header.change }), - } + ...request, + prompt: header.prompt, + requestConfig: header.prompt.config, + ...(header.change === undefined ? {} : { promptChange: header.change }), + } } function withRequestConfig( @@ -70,7 +70,7 @@ function captureSchemas( output: Map, ): void { const name = 'kind' in block ? block.call?.name : block.name - const schema = name === undefined || name === null + const schema = name === undefined ? undefined : tools.find(candidate => candidate.name === name) if (schema !== undefined) output.set(block.callId, schema) @@ -216,7 +216,11 @@ export const trajectoryViewDefinition: ConversationViewDefinition< create: () => new TrajectorySnapshotBuilder(), } -/** Register the legacy-shape Trajectory target builder. */ +/** + * Register the stage-oriented Trajectory target builder. + * + * @param ctx - Plugin context receiving the view Definition. + */ export function registerTrajectoryConversationView(ctx: Context): void { ctx.conversationViews.register(trajectoryViewDefinition) } diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 353d89070b..c72c6c8709 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -244,7 +244,11 @@ const trajectoryToolDefinition: ConversationNodeDefinition = { }, } -/** Register the Trajectory Tool lifecycle. */ +/** + * Register the Trajectory Tool lifecycle. + * + * @param ctx - Plugin context receiving the Definition. + */ export function registerTrajectoryToolDefinition(ctx: Context): void { ctx.conversationEvents.register(trajectoryToolDefinition) } diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index ab3f23d829..2439fb2de5 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -17,7 +17,6 @@ import { ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, EMPTY_CHAT_SNAPSHOT, } from '@deepseek-ai/dsh-client-runtime/client' -import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RequestView, @@ -88,7 +87,7 @@ function historySnapshot( sessionId: SID, views: { get: target => target === 'trajectory' ? trajectory : undefined, - } as ConversationSnapshot['views'], + }, chat: EMPTY_CHAT_SNAPSHOT, nodes, turnTimings: new Map(), @@ -136,7 +135,7 @@ function standaloneDuration(): Pick< function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore(historySnapshot(nodes)) - return { store, useSession: bindSnapshotSelector(store) as UseSession } + return { store, useSession: bindSnapshotSelector(store) } } /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ @@ -204,7 +203,7 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) - const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession + const useSession = bindSnapshotSelector(sessionSnapshot) const chat = createChatStore().create() const views = { list: () => tabsOf(slots), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 646244da69..40f2643417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1771,9 +1771,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy From 3469de58eb178e7662ece6ce061a26a7b7eaacd8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:26:54 +0800 Subject: [PATCH 05/17] fix(ui-trajectory): consume prompt changes once --- .../src/client/trajectory-snapshot-builder.ts | 11 +++- .../tests/snapshot-builder.spec.ts | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 packages/client/ui-trajectory/tests/snapshot-builder.spec.ts diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 717172c9d8..585975b0aa 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -46,6 +46,7 @@ function headerFor( function applyHeader( request: Extract, header: TrajectoryRequestHeaderState | undefined, + includeChange: boolean, ): Extract { return header === undefined ? request @@ -53,7 +54,7 @@ function applyHeader( ...request, prompt: header.prompt, requestConfig: header.prompt.config, - ...(header.change === undefined ? {} : { promptChange: header.change }), + ...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}), } } @@ -150,6 +151,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const boundaries: { seq: number; time: number }[] = [] const turnEndings: { turn: number; time: number; error?: string }[] = [] const callSchemas = new Map() + const consumedPromptChanges = new Set() let partial: TrajectorySnapshot['partial'] = null const runningCalls: TrajectorySnapshot['runningCalls'][number][] = [] @@ -163,7 +165,12 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const header = data.request === undefined ? undefined : headerFor(data.request, headers) if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) if (data.partial !== null) partial = data.partial - if (data.request !== undefined) requests.push(applyHeader(data.request, header)) + if (data.request !== undefined) { + const includeChange = header?.change !== undefined + && !consumedPromptChanges.has(header.seq) + requests.push(applyHeader(data.request, header, includeChange)) + if (includeChange) consumedPromptChanges.add(header.seq) + } continue } if (data.kind === 'tool') { diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts new file mode 100644 index 0000000000..87e484a433 --- /dev/null +++ b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryConversationViewNode } from '../src/client/trajectory-contract.ts' +import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts' + +function assistantRequest(startSeq: number, step: number): Extract { + return { + purpose: 'assistant', + startSeq, + turn: 1, + step, + startedAt: startSeq, + completedAt: startSeq + 1, + status: 'complete', + } +} + +describe('TrajectorySnapshotBuilder', () => { + it('inherits one request header across requests without repeating its prompt change', () => { + const prompt = { + config: { provider: 'test', model: 'test' }, + system: 'one initial prompt', + tools: [], + } + const nodes: TrajectoryConversationViewNode[] = [ + { + key: 'header', + kind: 'trajectory-request-header', + id: '2', + target: 'trajectory', + anchorSeq: 2, + data: { + kind: 'request-header', + header: { + seq: 2, + time: 2, + prompt, + change: { seq: 2, time: 2, kind: 'initial' }, + location: { kind: 'session' }, + }, + }, + }, + ...[assistantRequest(3, 1), assistantRequest(5, 2)].map(request => ({ + key: `assistant:${request.step}`, + kind: 'trajectory-assistant-step', + id: `1:${request.step}`, + target: 'trajectory' as const, + anchorSeq: request.startSeq, + data: { kind: 'assistant' as const, partial: null, request }, + })), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['one initial prompt', 'one initial prompt']) + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.promptChange?.kind + : undefined)).toEqual(['initial', undefined]) + }) +}) From fc4df896a089956594a94f4da945ae9c44659328 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:38:51 +0800 Subject: [PATCH 06/17] perf(ui-trajectory): index trajectory snapshot assembly --- .../src/client/trajectory-snapshot-builder.ts | 130 +++++++++---- .../tests/snapshot-builder.spec.ts | 171 +++++++++++++++++- 2 files changed, 262 insertions(+), 39 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 585975b0aa..2f4c697275 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -11,6 +11,8 @@ import type { const EMPTY_LIST: readonly never[] = [] const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }] +type AssistantRequest = Extract +type ToolSchema = ConversationPromptSnapshot['tools'][number] /** Stable empty target used until a Session has assembled Trajectory records. */ export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { @@ -23,31 +25,31 @@ export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { runningCalls: EMPTY_LIST, } -function coordinates( - header: TrajectoryRequestHeaderState, -): { turn?: number; step?: number } { +function stepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined { const location = header.location - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} + return location.kind === 'step' + ? stepKey(location.turn.turn, location.step.step) + : undefined } function headerFor( - request: Extract, - headers: readonly TrajectoryRequestHeaderState[], + request: AssistantRequest, + headersByStep: ReadonlyMap, + previous: TrajectoryRequestHeaderState | undefined, ): TrajectoryRequestHeaderState | undefined { - const exact = headers.findLast((header) => { - const location = coordinates(header) - return location.turn === request.turn && location.step === request.step - }) - return exact ?? headers.findLast(header => header.seq < request.startSeq) + return headersByStep.get(stepKey(request.turn, request.step)) + ?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined) } function applyHeader( - request: Extract, + request: AssistantRequest, header: TrajectoryRequestHeaderState | undefined, includeChange: boolean, -): Extract { +): AssistantRequest { return header === undefined ? request : { @@ -67,26 +69,39 @@ function withRequestConfig( function captureSchemas( block: ToolCallBlock, - tools: readonly ConversationPromptSnapshot['tools'][number][], - output: Map, + toolsByName: ReadonlyMap, + output: Map, ): void { const name = 'kind' in block ? block.call?.name : block.name - const schema = name === undefined - ? undefined - : tools.find(candidate => candidate.name === name) + const schema = name === undefined ? undefined : toolsByName.get(name) if (schema !== undefined) output.set(block.callId, schema) - for (const child of block.subCalls) captureSchemas(child, tools, output) + for (const child of block.subCalls) captureSchemas(child, toolsByName, output) +} + +function indexTools(tools: readonly ToolSchema[]): ReadonlyMap { + return new Map(tools.map(tool => [tool.name, tool])) } function interruptCompactions( requests: RequestView[], boundaries: readonly { seq: number; time: number }[], ): void { + let nextRequest = 0 + const runningCompactions: number[] = [] for (const boundary of boundaries) { - const index = requests.findLastIndex(request => - request.purpose === 'compaction' - && request.startSeq < boundary.seq - && request.status === 'running') + while (nextRequest < requests.length) { + const request = requests[nextRequest] + if (request === undefined || request.startSeq >= boundary.seq) break + if (request.purpose === 'compaction' && request.status === 'running') { + runningCompactions.push(nextRequest) + } + nextRequest++ + } + let index = runningCompactions.pop() + while (index !== undefined && requests[index]?.status !== 'running') { + index = runningCompactions.pop() + } + if (index === undefined) continue const request = requests[index] if (request?.purpose !== 'compaction') continue requests[index] = { @@ -102,10 +117,14 @@ function applyTurnErrors( requests: RequestView[], endings: readonly { turn: number; time: number; error?: string }[], ): void { + const lastAssistantByTurn = new Map() + for (const [index, request] of requests.entries()) { + if (request.purpose === 'assistant') lastAssistantByTurn.set(request.turn, index) + } for (const ending of endings) { if (ending.error === undefined) continue - const index = requests.findLastIndex(request => - request.purpose === 'assistant' && request.turn === ending.turn) + const index = lastAssistantByTurn.get(ending.turn) + if (index === undefined) continue const request = requests[index] if (request?.purpose !== 'assistant') continue requests[index] = { @@ -123,6 +142,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< TrajectorySnapshot > { private readonly nodes = new Map() + private readonly positions = new Map() + private contributions: TrajectoryConversationViewNode[] = [] readonly empty = EMPTY_TRAJECTORY_SNAPSHOT replace(input: { @@ -130,39 +151,62 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< }): TrajectorySnapshot { this.nodes.clear() for (const node of input.nodes) this.nodes.set(node.key, node) + this.rebuildContributions() return this.snapshot() } apply(input: { readonly upserts: readonly TrajectoryConversationViewNode[] }): TrajectorySnapshot { - for (const node of input.upserts) this.nodes.set(node.key, node) + let structural = false + for (const node of input.upserts) { + const previous = this.nodes.get(node.key) + this.nodes.set(node.key, node) + if (previous === undefined || previous.anchorSeq !== node.anchorSeq) { + structural = true + continue + } + const position = this.positions.get(node.key) + if (position === undefined) structural = true + else this.contributions[position] = node + } + if (structural) this.rebuildContributions() return this.snapshot() } private snapshot(): TrajectorySnapshot { - const contributions = [...this.nodes.values()] - .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) - const headers = contributions.flatMap(node => node.data.kind === 'request-header' - ? [node.data.header] - : []) + const headersByStep = new Map() + for (const contribution of this.contributions) { + if (contribution.data.kind !== 'request-header') continue + const key = headerStepKey(contribution.data.header) + if (key !== undefined) headersByStep.set(key, contribution.data.header) + } const finalized: ConversationNode[] = [] const requests: RequestView[] = [] const boundaries: { seq: number; time: number }[] = [] const turnEndings: { turn: number; time: number; error?: string }[] = [] - const callSchemas = new Map() + const callSchemas = new Map() const consumedPromptChanges = new Set() + let previousHeader: TrajectoryRequestHeaderState | undefined + let previousTools: ReadonlyMap = new Map() let partial: TrajectorySnapshot['partial'] = null const runningCalls: TrajectorySnapshot['runningCalls'][number][] = [] - for (const contribution of contributions) { + for (const contribution of this.contributions) { const data = contribution.data + if (data.kind === 'request-header') { + previousHeader = data.header + previousTools = indexTools(data.header.prompt.tools) + continue + } if (data.kind === 'node') { finalized.push(data.node) continue } if (data.kind === 'assistant') { - const header = data.request === undefined ? undefined : headerFor(data.request, headers) + const header = data.request === undefined + ? undefined + : headerFor(data.request, headersByStep, previousHeader) if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) if (data.partial !== null) partial = data.partial if (data.request !== undefined) { @@ -176,8 +220,9 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< if (data.kind === 'tool') { if ('kind' in data.root) finalized.push(data.root) else runningCalls.push(data.root) - const header = headers.findLast(candidate => candidate.seq < contribution.anchorSeq) - if (header !== undefined) captureSchemas(data.root, header.prompt.tools, callSchemas) + if (previousHeader !== undefined && previousHeader.seq < contribution.anchorSeq) { + captureSchemas(data.root, previousTools, callSchemas) + } continue } if (data.kind === 'compaction') { @@ -212,6 +257,15 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< runningCalls, } } + + private rebuildContributions(): void { + this.contributions = [...this.nodes.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) + this.positions.clear() + for (const [index, contribution] of this.contributions.entries()) { + this.positions.set(contribution.key, index) + } + } } /** Trajectory target factory preserving the existing stage-oriented view model. */ diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts index 87e484a433..d3cf64ac2d 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' -import type { TrajectoryConversationViewNode } from '../src/client/trajectory-contract.ts' +import type { + TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState, +} from '../src/client/trajectory-contract.ts' import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts' function assistantRequest(startSeq: number, step: number): Extract { @@ -15,6 +17,47 @@ function assistantRequest(startSeq: number, step: number): Extract undefined } + const stepLocation = { + turn, + step, + start: undefined, + end: undefined, + status: 'unknown' as const, + data, + } + const turnLocation = { + turn, + start: undefined, + end: undefined, + status: 'unknown' as const, + steps: [stepLocation], + data, + } + return { kind: 'step', turn: turnLocation, step: stepLocation } +} + +function compactionRequest(startSeq: number): Extract { + return { + purpose: 'compaction', + startSeq, + turn: null, + step: 0, + startedAt: startSeq, + completedAt: null, + status: 'running', + } +} + describe('TrajectorySnapshotBuilder', () => { it('inherits one request header across requests without repeating its prompt change', () => { const prompt = { @@ -59,4 +102,130 @@ describe('TrajectorySnapshotBuilder', () => { ? request.promptChange?.kind : undefined)).toEqual(['initial', undefined]) }) + + it('indexes exact step headers and the active tool schema without backward scans', () => { + const basePrompt = { + config: { provider: 'test', model: 'base' }, + system: 'base prompt', + tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }], + } + const exactPrompt = { + config: { provider: 'test', model: 'exact' }, + system: 'exact prompt', + tools: [{ name: 'edit', description: 'Edit', parameters: { type: 'object' } }], + } + const nodes: TrajectoryConversationViewNode[] = [ + contribution('header:base', 2, { + kind: 'request-header', + header: { + seq: 2, + time: 2, + prompt: basePrompt, + change: { seq: 2, time: 2, kind: 'initial' }, + location: { kind: 'session' }, + }, + }), + contribution('assistant:1', 3, { + kind: 'assistant', + partial: null, + request: assistantRequest(3, 1), + }), + contribution('assistant:2', 5, { + kind: 'assistant', + partial: null, + request: assistantRequest(5, 2), + }), + contribution('header:exact', 6, { + kind: 'request-header', + header: { + seq: 6, + time: 6, + prompt: exactPrompt, + change: { seq: 6, time: 6, kind: 'system', previous: basePrompt }, + location: stepLocation(1, 2), + }, + }), + contribution('tool', 7, { + kind: 'tool', + root: { + callId: 'call-edit', + name: 'edit', + argsRaw: '{}', + turn: 1, + step: 2, + time: 7, + callView: null, + subCalls: [], + }, + }), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['base prompt', 'exact prompt']) + expect(snapshot.callSchemas.get('call-edit')).toEqual(exactPrompt.tools[0]) + }) + + it('applies session boundaries and turn errors with linear request indexes', () => { + const nodes: TrajectoryConversationViewNode[] = [ + ...[assistantRequest(1, 1), assistantRequest(3, 2)].map(request => contribution( + `assistant:${request.step}`, + request.startSeq, + { kind: 'assistant', partial: null, request }, + )), + contribution('turn-end', 5, { + kind: 'turn-end', + turn: 1, + time: 5, + error: 'turn failed', + }), + contribution('compact:10', 10, { + kind: 'compaction', + request: compactionRequest(10), + }), + contribution('compact:12', 12, { + kind: 'compaction', + request: compactionRequest(12), + }), + contribution('session-end:14', 14, { kind: 'session-end', seq: 14, time: 14 }), + contribution('session-end:16', 16, { kind: 'session-end', seq: 16, time: 16 }), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests).toMatchObject([ + { purpose: 'assistant', step: 1, status: 'complete' }, + { purpose: 'assistant', step: 2, status: 'error', error: 'turn failed' }, + { purpose: 'compaction', startSeq: 10, status: 'error', completedAt: 16 }, + { purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 }, + ]) + }) + + it('keeps cached contribution order across content updates and structural inserts', () => { + const builder = new TrajectorySnapshotBuilder() + const first = contribution('assistant:1', 1, { + kind: 'assistant', partial: null, request: assistantRequest(1, 1), + }) + const last = contribution('assistant:3', 5, { + kind: 'assistant', partial: null, request: assistantRequest(5, 3), + }) + expect(builder.replace({ nodes: [last, first] }).requests.map(request => request.startSeq)) + .toEqual([1, 5]) + + const updatedLast = contribution('assistant:3', 5, { + kind: 'assistant', + partial: null, + request: { ...assistantRequest(5, 3), status: 'error', error: 'failed' }, + }) + expect(builder.apply({ upserts: [updatedLast] }).requests.map(request => request.startSeq)) + .toEqual([1, 5]) + + const middle = contribution('assistant:2', 3, { + kind: 'assistant', partial: null, request: assistantRequest(3, 2), + }) + expect(builder.apply({ upserts: [middle] }).requests.map(request => request.startSeq)) + .toEqual([1, 3, 5]) + }) }) From b3d3e423f22a77d99d9f4660b5dae51679a9958c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:54:37 +0800 Subject: [PATCH 07/17] fix(ui-trajectory): retain parallel tool interruptions --- .../src/client/TrajectoryView.tsx | 5 ++-- .../src/client/context-branches.ts | 19 ++++++++++--- .../tests/context-branches.spec.ts | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 95476ac851..70e92f72fd 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -9,6 +9,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, + trajectoryNodeIdentity, } from './context-branches.ts' import { TrajectoryTable, @@ -229,9 +230,9 @@ export function TrajectoryView({ const currentBranch = branches.at(-1) if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty') const selectedNodes = useMemo(() => { - const selected = new Map(currentBranch.nodes.map(node => [node.seq, node])) + const selected = new Map(currentBranch.nodes.map(node => [trajectoryNodeIdentity(node), node])) for (const node of interruptedNodes) { - selected.set(node.seq, node) + selected.set(trajectoryNodeIdentity(node), node) } return [...selected.values()].sort((left, right) => left.seq - right.seq) }, [currentBranch.nodes, interruptedNodes]) diff --git a/packages/client/ui-trajectory/src/client/context-branches.ts b/packages/client/ui-trajectory/src/client/context-branches.ts index 2501511c50..2f665bb413 100644 --- a/packages/client/ui-trajectory/src/client/context-branches.ts +++ b/packages/client/ui-trajectory/src/client/context-branches.ts @@ -23,11 +23,24 @@ interface MutableBranch { key: string contexts: ConversationContext[] latest: ConversationContext - nodes: Map + nodes: Map startSeq: number retainedSurfaceSeqs: Set } +/** + * Resolve the identity used while coalescing one trajectory branch. + * Synthetic tool interruptions share their closing boundary seq, so their + * call ids distinguish parallel roots without inventing false event order. + * @param node - projected conversation node. + * @returns branch-local semantic identity. + */ +export function trajectoryNodeIdentity(node: ConversationNode): string { + return node.kind === 'tool-result' + ? `tool-result\u0000${String(node.seq)}\u0000${node.callId}` + : `seq\u0000${String(node.seq)}` +} + function isCompactionCheckpoint(node: ConversationNode): boolean { if (node.kind !== 'context') return false const source = node.source @@ -73,7 +86,7 @@ export function deriveTrajectoryContextBranches( latest: context, nodes: new Map( [...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))] - .map(node => [node.seq, node]), + .map(node => [trajectoryNodeIdentity(node), node]), ), startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY, retainedSurfaceSeqs, @@ -85,7 +98,7 @@ export function deriveTrajectoryContextBranches( branch.contexts.push(context) branch.latest = context for (const node of context.nodes) { - if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node) + if (!isCompactionCheckpoint(node)) branch.nodes.set(trajectoryNodeIdentity(node), node) } } return mutable.map(branch => ({ diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index e608b9fd68..9885e9a4f9 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -34,6 +34,23 @@ const current = { source: { kind: 'plugin', plugin: 'rewind' }, } as ConversationNode +function interruptedTool(callId: string): ConversationNode { + return { + kind: 'tool-result', + seq: 19.2, + time: 20, + callId, + call: { name: 'parallel', argsRaw: '{}' }, + callTime: 10, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: null, + resultView: null, + subCalls: [], + } +} + function request( purpose: RequestView['purpose'], startSeq: number, @@ -99,4 +116,14 @@ describe('trajectory context branches', () => { expect(branch(1)?.key).toBe(branch(9)?.key) }) + + it('retains parallel tool interruptions that share one closing boundary', () => { + const branch = deriveTrajectoryContextBranches([{ + id: 0, + nodes: [interruptedTool('call-a'), interruptedTool('call-b')], + }])[0] + + expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined)) + .toEqual(['call-a', 'call-b']) + }) }) From 62f5d050390904852b8308b6bd4fee0246265d84 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:35:57 +0800 Subject: [PATCH 08/17] fix(ui-trajectory): tighten conversation assembly contracts --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 8 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 8 +- .../adding-a-conversation-node.i18n.yaml | 4 +- docs/cookbook/adding-a-conversation-node.md | 7 +- .../cookbook/adding-a-conversation-node.zh.md | 7 +- .../tests/conversation-assembler.spec.ts | 107 +++++-- .../tests/conversation-registry.spec.ts | 34 +++ .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- packages/client/ui-trajectory/package.json | 6 + .../src/client/TrajectoryView.tsx | 66 +---- .../src/client/context-branches.ts | 135 --------- .../client/ui-trajectory/src/client/index.ts | 4 +- .../client/trajectory-assistant-definition.ts | 4 + .../src/client/trajectory-contract.ts | 4 +- .../client/trajectory-definition-common.ts | 16 +- .../client/trajectory-message-definitions.ts | 4 + .../src/client/trajectory-snapshot-builder.ts | 5 - .../src/client/trajectory-tool-definition.ts | 25 +- .../ui-trajectory/tests/client-bundle.spec.ts | 6 + .../tests/context-branches.spec.ts | 129 -------- .../tests/conversation-definitions.spec.ts | 276 ++++++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 174 ++--------- packages/client/ui-trajectory/tsconfig.json | 9 + pnpm-lock.yaml | 9 + 27 files changed, 525 insertions(+), 534 deletions(-) delete mode 100644 packages/client/ui-trajectory/src/client/context-branches.ts delete mode 100644 packages/client/ui-trajectory/tests/context-branches.spec.ts create mode 100644 packages/client/ui-trajectory/tests/conversation-definitions.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 1e7284378c..38bd4b3d9e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: c09213d35e984ca717d283d45259f61d413407a3 -2026-07-27-trajectory-inspection-ledger.zh.md: 9d2c615dea5b0774201118a0b0abb9862a228690 +2026-07-27-trajectory-inspection-ledger.md: c46b9dbc564a8c3c83792335427614c92a015fce +2026-07-27-trajectory-inspection-ledger.zh.md: 20811f7a23fe1c9ee69dce24c975f6343eaff6df diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index c09213d35e..c46b9dbc56 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -12,17 +12,17 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** -- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. +- The ledger keeps materialized business records in Session Event order within the loaded window. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. -- Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. Its Definitions and target builder derive event order, context lineage, schema index, and Requests without making those structures part of the Chat snapshot. +- Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. Its Definitions and target builder derive event order, the schema index, and Requests without making those structures part of the Chat snapshot. - Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger. @@ -53,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport work, virtualization bounds mounted row elements, exact-ID dispatch avoids re-folding unrelated business Contexts, and animation-frame publication caps streaming snapshot frequency. The retained stage-oriented target builder may still perform work proportional to the loaded materialized Nodes for a publication; this migration does not add a stronger Trajectory-specific complexity guarantee. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions appear as the current materialized business records in sequence with surrounding history. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport work, virtualization bounds mounted row elements, exact-ID dispatch avoids re-folding unrelated business Contexts, and animation-frame publication caps streaming snapshot frequency. The retained stage-oriented target builder may still perform work proportional to the loaded materialized Nodes for a publication; this migration does not add a stronger Trajectory-specific complexity guarantee. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 9d2c615dea..20811f7a23 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -12,17 +12,17 @@ Status: implemented **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** -- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 记录表在已加载窗口内按 Session Event 顺序展示物化后的业务记录。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、上下文谱系、schema 索引和请求,无须把这些结构放进 Chat snapshot。 +- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、schema 索引和请求,无须把这些结构放进 Chat snapshot。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。 @@ -53,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Context,animation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩会作为当前物化的业务记录,按顺序出现在周边历史中。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Context,animation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/cookbook/adding-a-conversation-node.i18n.yaml index aa268e9461..52234b5562 100644 --- a/docs/cookbook/adding-a-conversation-node.i18n.yaml +++ b/docs/cookbook/adding-a-conversation-node.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-conversation-node.md -adding-a-conversation-node.md: ea4ec73eb109af6b0e4c7cf50fc8692942c75dd4 -adding-a-conversation-node.zh.md: 4b9a8049e2f1d060ec4bc3334036559b989ea562 +adding-a-conversation-node.md: c1965dc8a3081eebb8c1026ac53d2f7b8964edb7 +adding-a-conversation-node.zh.md: 92445e1432369a4e42cc372b5d5869c3cdeada4a diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/cookbook/adding-a-conversation-node.md index ea4ec73eb1..c1965dc8a3 100644 --- a/docs/cookbook/adding-a-conversation-node.md +++ b/docs/cookbook/adding-a-conversation-node.md @@ -120,6 +120,7 @@ function viewData(state: ReviewState): ReviewChatData { const reviewDefinition: ConversationNodeDefinition = { kind: 'review-job', + target: 'chat', match: (event) => { if (event.type === 'review/start') { return { id: String(event.data.reviewId), role: 'start' } @@ -161,8 +162,8 @@ const reviewDefinition: ConversationNodeDefinition = { value: viewData(context.state), } }, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined) return null return { key: context.key, kind: 'review-job', @@ -196,7 +197,7 @@ export function apply(ctx: ClientContext): void { `buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. -`buildViewNode(context, target)` materializes the final target-specific Node. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. +`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. ## 3. Query an earlier business Context only at start diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/cookbook/adding-a-conversation-node.zh.md index 4b9a8049e2..92445e1432 100644 --- a/docs/cookbook/adding-a-conversation-node.zh.md +++ b/docs/cookbook/adding-a-conversation-node.zh.md @@ -120,6 +120,7 @@ function viewData(state: ReviewState): ReviewChatData { const reviewDefinition: ConversationNodeDefinition = { kind: 'review-job', + target: 'chat', match: (event) => { if (event.type === 'review/start') { return { id: String(event.data.reviewId), role: 'start' } @@ -161,8 +162,8 @@ const reviewDefinition: ConversationNodeDefinition = { value: viewData(context.state), } }, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined) return null return { key: context.key, kind: 'review-job', @@ -196,7 +197,7 @@ export function apply(ctx: ClientContext): void { `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 -`buildViewNode(context, target)` 物化最终的目标专用 Node。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 +`target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 ## 3. 只在 start 时查询更早的业务 Context diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts index 50380a20a3..6108169192 100644 --- a/packages/client/runtime/tests/conversation-assembler.spec.ts +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -37,8 +37,8 @@ class TestEventDefinitions { definitions: readonly ConversationNodeDefinition[], fallback?: ConversationNodeDefinition, ) { - this.definitions = definitions.map(asChatDefinition) - this.fallback = fallback === undefined ? undefined : asChatDefinition(fallback) + this.definitions = definitions + this.fallback = fallback } entries(): readonly ConversationNodeDefinition[] { @@ -50,12 +50,6 @@ class TestEventDefinitions { } } -function asChatDefinition(definition: ConversationNodeDefinition): ConversationNodeDefinition { - return definition.buildViewNode === undefined || definition.target !== undefined - ? definition - : { ...definition, target: 'chat' } -} - class TestViewDefinitions { constructor(readonly definitions: readonly ConversationViewDefinition[]) {} @@ -118,6 +112,17 @@ function node( } } +function fallbackDefinition(start: () => string): ConversationNodeDefinition { + return { + kind: 'fallback', + target: 'chat', + match: event => ({ id: String(event.seq), role: 'start' }), + start, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } +} + describe('ConversationNodeAssembler', () => { it('appends through an exact business-id Context without replaying unrelated Contexts', () => { const starts = vi.fn(( @@ -137,6 +142,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -188,6 +194,7 @@ describe('ConversationNodeAssembler', () => { matchCollections.add(context.matches) return updates(context) }, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -223,6 +230,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -262,6 +270,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => ({ settled: false }), update: updates, + target: 'chat', buildViewNode: context => node(context, context.state ?? { pendingStart: true }), } const assembler = new ConversationNodeAssembler( @@ -295,6 +304,7 @@ describe('ConversationNodeAssembler', () => { : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const assembler = new ConversationNodeAssembler( @@ -316,6 +326,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), update: context => context.state, + target: 'chat', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -330,6 +341,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -359,6 +371,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => match.event.seq, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const consumer: ConversationNodeDefinition = { @@ -368,6 +381,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -412,6 +426,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -440,6 +455,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, + target: 'chat', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -454,6 +470,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -483,6 +500,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, + target: 'chat', buildViewNode: () => null, } const sourceX: ConversationNodeDefinition = { @@ -494,6 +512,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 10, update: (_context, match) => (match.event.data as unknown as { value: number }).value, + target: 'chat', buildViewNode: () => null, } const middle: ConversationNodeDefinition = { @@ -506,6 +525,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-x')?.state ?? 0) ), update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const consumer: ConversationNodeDefinition = { @@ -518,6 +538,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-b')?.state ?? 0) ), update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -553,6 +574,7 @@ describe('ConversationNodeAssembler', () => { : null, start: starts, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -624,6 +646,7 @@ describe('ConversationNodeAssembler', () => { value: { valueSeenFromStep: stepValue ?? -1 }, } }, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location if (location?.kind !== 'step') return null @@ -661,6 +684,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.start?.location.kind === 'turn' ? context.start.location.turn.steps.length : -1), @@ -716,6 +740,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location const data = location?.kind === 'step' @@ -749,6 +774,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.start?.location.kind), } const assembler = new ConversationNodeAssembler( @@ -775,6 +801,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -807,6 +834,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -841,6 +869,7 @@ describe('ConversationNodeAssembler', () => { : null, start: seen, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -856,24 +885,64 @@ describe('ConversationNodeAssembler', () => { expect(seen).toHaveBeenCalledTimes(2) }) - it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => { + it('invokes the fallback when only a State-only Definition claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed-state', + match: event => (event.type as string) === 'command/run' + ? { id: 'claimed', role: 'start' } + : null, + start: () => null, + update: context => context.state, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), + new TestViewDefinitions([testView()]), + ) + + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('invokes the fallback when only another target claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed-trajectory', + target: 'trajectory', + match: event => (event.type as string) === 'command/run' + ? { id: 'claimed', role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), + new TestViewDefinitions([testView()]), + ) + + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('suppresses the fallback when the same target claims an event', () => { const fallbackStart = vi.fn(() => 'fallback') const claimed: ConversationNodeDefinition = { kind: 'claimed', + target: 'chat', match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, start: () => null, update: context => context.state, buildViewNode: () => null, } - const fallback: ConversationNodeDefinition = { - kind: 'fallback', - match: event => ({ id: String(event.seq), role: 'start' }), - start: fallbackStart, - update: context => context.state, - buildViewNode: context => node(context, context.state), - } const assembler = new ConversationNodeAssembler( - new TestEventDefinitions([claimed], fallback), + new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), new TestViewDefinitions([testView()]), ) assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) @@ -893,6 +962,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => false, + target: 'chat', buildViewNode: context => context.state === true ? node(context, true) : null, } const assembler = new ConversationNodeAssembler( @@ -915,6 +985,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: () => undefined, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const startAssembler = new ConversationNodeAssembler( @@ -934,6 +1005,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => undefined as never, + target: 'chat', buildViewNode: context => node(context, context.state), } const updateAssembler = new ConversationNodeAssembler( @@ -954,6 +1026,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: (_context, match) => match.event.seq, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 0beaf1d5c2..5181dab926 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -72,6 +72,40 @@ describe('Conversation registries', () => { expect(events.fallbackEntry()).toBeUndefined() }) + it('rejects rendering Definitions that omit either target or builder', async () => { + const { events } = await bootRegistries() + const targetOnly: ConversationNodeDefinition = { + kind: 'target-only', + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + } + const builderOnly: ConversationNodeDefinition = { + kind: 'builder-only', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/) + expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/) + }) + + it('rejects a State-only Definition as the unmatched-event fallback', async () => { + const { events } = await bootRegistries() + const fallback: ConversationNodeDefinition = { + kind: 'state-only-fallback', + match: () => null, + start: () => null, + update: context => context.state, + } + + expect(() => events.registerFallback(fallback)) + .toThrow('conversation fallback Definition must declare a target') + }) + it('rejects duplicate view targets and disposes a view registration once', async () => { const { views } = await bootRegistries() const definition = viewDefinition('chat') diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 17551b0cc8..78082eb1f0 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: 75bd9ddf452634460be01e1b89cd5a1a14a1593f -README.zh.md: b5cd53dd50e43b96e2e832c96cb7e93f859c1993 +README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4 +README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 75bd9ddf45..d3786b6460 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble context lineage and cancellation-frozen Assistant and Tool records from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index b5cd53dd50..5eb1451b9a 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装上下文谱系,以及因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index c11b607939..a0c76575ae 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -48,19 +48,25 @@ "diff": "^9.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 70e92f72fd..5651e620e5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -4,13 +4,9 @@ import { useCallback, useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, + AssistantBlock, AssistantMessageNode, ConversationSnapshot, SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' -import { - deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, - trajectoryNodeIdentity, -} from './context-branches.ts' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -190,10 +186,7 @@ export function TrajectoryView({ const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) - const [timelineSelection, setTimelineSelection] = useState<{ - branchKey: string - range: TrajectoryTimeRange - } | null>(null) + const [timelineSelection, setTimelineSelection] = useState(null) const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -215,41 +208,8 @@ export function TrajectoryView({ const runningCalls = inspection.runningCalls const requests = inspection.requests const callSchemas = inspection.callSchemas - const historyContexts = inspection.contexts - const interruptedNodes = inspection.interruptedNodes - const contexts = useMemo( - () => historyContexts.length === 0 - ? [{ id: 0, nodes }] - : historyContexts, - [historyContexts, nodes], - ) - const branches = useMemo( - () => deriveTrajectoryContextBranches(contexts), - [contexts], - ) - const currentBranch = branches.at(-1) - if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty') - const selectedNodes = useMemo(() => { - const selected = new Map(currentBranch.nodes.map(node => [trajectoryNodeIdentity(node), node])) - for (const node of interruptedNodes) { - selected.set(trajectoryNodeIdentity(node), node) - } - return [...selected.values()].sort((left, right) => left.seq - right.seq) - }, [currentBranch.nodes, interruptedNodes]) - const selectedRequests = useMemo( - () => requests.filter(request => - trajectoryBranchContainsRequest(currentBranch, request), - ), - [currentBranch, requests], - ) const requestNumbers = useMemo(() => { const assistantsByStep = new Map() - for (const context of contexts) { - for (const node of context.nodes) { - if (node.kind !== 'assistant' || node.step <= 0) continue - assistantsByStep.set(`${node.turn}\u0000${node.step}`, node) - } - } for (const node of nodes) { if (node.kind !== 'assistant' || node.step <= 0) continue assistantsByStep.set(`${node.turn}\u0000${node.step}`, node) @@ -345,24 +305,24 @@ export function TrajectoryView({ return numbered }, [ - contexts, nodes, requests, + nodes, requests, ]) const partialTurn = partial?.turn ?? null const partialStep = partial?.step ?? null const finalized = useMemo(() => { const turns = deriveTrajectoryLayout({ - nodes: selectedNodes, + nodes, partial: partialTurn === null || partialStep === null ? null : { turn: partialTurn, step: partialStep, blocks: [] }, runningCalls, - requests: selectedRequests, + requests, callSchemas, }) return { turns, lastIndex: lastCellIndex(turns) } }, [ - selectedNodes, partialTurn, partialStep, - runningCalls, selectedRequests, callSchemas, + nodes, partialTurn, partialStep, + runningCalls, requests, callSchemas, ]) const timelinePartialSignature = partialStructureSignature(partial) const timelinePartial = useMemo(() => partial === null @@ -402,9 +362,7 @@ export function TrajectoryView({ () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), [finalizedSearchMatches, partialSearchMatches], ) - const timelineRange = timelineSelection?.branchKey === currentBranch.key - ? timelineSelection.range - : null + const timelineRange = timelineSelection const timelineFocusIndexes = useMemo( () => timelineRange === null ? null @@ -420,11 +378,8 @@ export function TrajectoryView({ } }, [timelineFocusIndexes]) const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => { - setTimelineSelection(range === null ? null : { - branchKey: currentBranch.key, - range, - }) - }, [currentBranch.key]) + setTimelineSelection(range) + }, []) const handleTimelineRecordSelect = useCallback((index: number) => { setTimelineSelection(null) setTimelineRecordSelection({ index }) @@ -547,7 +502,6 @@ export function TrajectoryView({ />
-} - -interface MutableBranch { - id: number - key: string - contexts: ConversationContext[] - latest: ConversationContext - nodes: Map - startSeq: number - retainedSurfaceSeqs: Set -} - -/** - * Resolve the identity used while coalescing one trajectory branch. - * Synthetic tool interruptions share their closing boundary seq, so their - * call ids distinguish parallel roots without inventing false event order. - * @param node - projected conversation node. - * @returns branch-local semantic identity. - */ -export function trajectoryNodeIdentity(node: ConversationNode): string { - return node.kind === 'tool-result' - ? `tool-result\u0000${String(node.seq)}\u0000${node.callId}` - : `seq\u0000${String(node.seq)}` -} - -function isCompactionCheckpoint(node: ConversationNode): boolean { - if (node.kind !== 'context') return false - const source = node.source - return typeof source === 'object' - && source !== null - && 'kind' in source - && source.kind === 'plugin' - && 'plugin' in source - && source.plugin === 'compact' -} - -/** - * Join context generations across compaction/rewrite operations and split only at rewind. - * @param contexts - Append-only context generations from the runtime fold. - * @returns Rewind-delimited branches in creation order. - */ -export function deriveTrajectoryContextBranches( - contexts: readonly ConversationContext[], -): readonly TrajectoryContextBranch[] { - const mutable: MutableBranch[] = [] - for (const context of contexts) { - const startsBranch = mutable.length === 0 || context.origin === 'rewind' - if (startsBranch) { - const previous = mutable.at(-1) - const retainedSurfaceSeqs = new Set( - context.nodes - .filter(node => - context.originSeq !== undefined && node.seq < context.originSeq, - ) - .map(node => node.seq), - ) - const inheritedNodes = previous === undefined - ? [] - : [...previous.nodes.values()].filter(node => - retainedSurfaceSeqs.has(node.seq), - ) - mutable.push({ - id: context.id, - key: context.origin === 'rewind' && context.originSeq !== undefined - ? `rewind:${context.originSeq}` - : 'root', - contexts: [context], - latest: context, - nodes: new Map( - [...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))] - .map(node => [trajectoryNodeIdentity(node), node]), - ), - startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY, - retainedSurfaceSeqs, - }) - continue - } - const branch = mutable.at(-1) - if (branch === undefined) continue - branch.contexts.push(context) - branch.latest = context - for (const node of context.nodes) { - if (!isCompactionCheckpoint(node)) branch.nodes.set(trajectoryNodeIdentity(node), node) - } - } - return mutable.map(branch => ({ - id: branch.id, - key: branch.key, - contexts: branch.contexts, - latest: branch.latest, - nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq), - startSeq: branch.startSeq, - retainedSurfaceSeqs: branch.retainedSurfaceSeqs, - })) -} - -/** - * Test whether a provider request belongs to one rewind branch. - * @param branch - Branch carrying the exact inherited surface event seqs. - * @param request - Provider request to classify. - * @returns Whether the request began on this branch or produced a retained surface record. - */ -export function trajectoryBranchContainsRequest( - branch: TrajectoryContextBranch, - request: RequestView, -): boolean { - if (request.startSeq >= branch.startSeq) return true - return ( - request.resultSeq !== undefined - && branch.retainedSurfaceSeqs.has(request.resultSeq) - ) || ( - request.purpose === 'compaction' - && - request.replacementSeq !== undefined - && branch.retainedSurfaceSeqs.has(request.replacementSeq) - ) -} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 1f48a4711d..a8a41f5183 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -45,9 +45,9 @@ export function apply(ctx: Context): void { return { hooks: { duration }, loadOlder: async () => { - const hadMore = session.getSnapshot().hasMore + const before = session.getSnapshot().views.get('trajectory') await session.loadOlder() - return hadMore + return session.getSnapshot().views.get('trajectory') !== before }, setActualDuration: (value) => { duration.set(value) }, } diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index d610f6979b..717b4855d7 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -9,6 +9,9 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import { trajectoryNode } from './trajectory-definition-common.ts' +/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event + * state machines independent; see ../../../../../.agents/notes/implemented/ + * architecture/2026-08-09-client-conversation-node-assembly.md. */ interface UsageValue { readonly inputTokens: number readonly outputTokens: number @@ -389,6 +392,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition = { ...(context.state.error === undefined ? {} : { error: context.state.error }), }), } +/* jscpd:ignore-end */ /** * Register the Trajectory Assistant lifecycle. diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index e261eeb9fc..3e877a7969 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -1,5 +1,5 @@ import type { - AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode, + AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot, ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-runtime/client' @@ -59,10 +59,8 @@ export interface TrajectoryConversationViewNode extends ConversationViewNode { /** Stage-oriented Trajectory data assembled from registered business Contexts. */ export interface TrajectorySnapshot { readonly eventNodes: readonly ConversationNode[] - readonly contexts: readonly ConversationContext[] readonly requests: readonly RequestView[] readonly callSchemas: ReadonlyMap - readonly interruptedNodes: readonly ConversationNode[] readonly partial: PartialAssistant | null readonly runningCalls: readonly RunningToolCall[] } diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts index 8c9c7d6489..639b1ad3ea 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -1,22 +1,8 @@ -import type { - ConversationLocation, ConversationNodeContext, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client' import type { TrajectoryContribution, TrajectoryConversationViewNode, } from './trajectory-contract.ts' -/** - * Resolve the best loaded Location for one target-local Context. - * - * @param context - Context whose loaded matches provide the Location. - * @returns The start Location, first-match Location, or unresolved fallback. - */ -export function trajectoryContextLocation( - context: ConversationNodeContext, -): ConversationLocation { - return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } -} - /** * Wrap one contribution in the Engine-owned target envelope. * diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index a35b6080db..c8861c85a7 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -9,6 +9,9 @@ import { import type {} from '@deepseek-ai/dsh-agent/types' import { trajectoryNode } from './trajectory-definition-common.ts' +/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event + * state machines independent; see ../../../../../.agents/notes/implemented/ + * architecture/2026-08-09-client-conversation-node-assembly.md. */ interface InboxIdentity { readonly id: string } @@ -106,6 +109,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { ? null : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), } +/* jscpd:ignore-end */ /** * Register Trajectory-owned inbox classification and message records. diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 2f4c697275..a7f2de6be4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -10,17 +10,14 @@ import type { } from './trajectory-contract.ts' const EMPTY_LIST: readonly never[] = [] -const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }] type AssistantRequest = Extract type ToolSchema = ConversationPromptSnapshot['tools'][number] /** Stable empty target used until a Session has assembled Trajectory records. */ export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { eventNodes: EMPTY_LIST, - contexts: EMPTY_CONTEXTS, requests: EMPTY_LIST, callSchemas: new Map(), - interruptedNodes: EMPTY_LIST, partial: null, runningCalls: EMPTY_LIST, } @@ -249,10 +246,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const eventNodes = finalized return { eventNodes, - contexts: [{ id: 0, nodes: eventNodes }], requests, callSchemas, - interruptedNodes: EMPTY_LIST, partial, runningCalls, } diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index c72c6c8709..201ac15d72 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -6,6 +6,9 @@ import type { import type {} from '@deepseek-ai/dsh-tools/types' import { trajectoryNode } from './trajectory-definition-common.ts' +/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event + * state machines independent; see ../../../../../.agents/notes/implemented/ + * architecture/2026-08-09-client-conversation-node-assembly.md. */ const MAX_DEPTH = 256 interface ToolState { @@ -109,11 +112,26 @@ function childResult( function acceptsEdge(state: ToolState, parent: string, child: string): boolean { if (parent === child || state.parents.has(child)) return false let cursor: string | undefined = parent - for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) { - if (cursor === child) return false + let parentDepth = 0 + const ancestors = new Set() + while (cursor !== undefined) { + if (cursor === child || ancestors.has(cursor)) return false + ancestors.add(cursor) + parentDepth++ cursor = state.parents.get(cursor) } - return cursor === undefined + const pending = [{ callId: child, depth: 1 }] + const descendants = new Set() + let subtreeDepth = 0 + for (const candidate of pending) { + if (descendants.has(candidate.callId)) return false + descendants.add(candidate.callId) + subtreeDepth = Math.max(subtreeDepth, candidate.depth) + for (const nested of state.children.get(candidate.callId) ?? []) { + pending.push({ callId: nested, depth: candidate.depth + 1 }) + } + } + return parentDepth + subtreeDepth <= MAX_DEPTH } function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { @@ -243,6 +261,7 @@ const trajectoryToolDefinition: ConversationNodeDefinition = { return trajectoryNode(context, anchorSeq, { kind: 'tool', root }) }, } +/* jscpd:ignore-end */ /** * Register the Trajectory Tool lifecycle. diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 902363b719..9590f28b5e 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -84,9 +84,15 @@ describe('tsdown client artifact', () => { ctx.provide('sessions', { binding: () => undefined }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() + const events = ctx.get('conversationEvents') as ConversationEventRegistry + const views = ctx.get('conversationViews') as ConversationViewRegistry expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) + expect(events.entries().length).toBeGreaterThan(0) + expect(views.entries()).toHaveLength(1) await fiber.dispose() expect(slots.entries('conversation.view')).toHaveLength(0) + expect(events.entries()).toEqual([]) + expect(views.entries()).toEqual([]) }) it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => { diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts deleted file mode 100644 index 9885e9a4f9..0000000000 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - ConversationContext, ConversationNode, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' -import { - deriveTrajectoryContextBranches, - trajectoryBranchContainsRequest, -} from '../src/client/context-branches.ts' - -const checkpoint = { - kind: 'context', - seq: 100, - time: 100, - content: [], - source: { kind: 'plugin', plugin: 'compact' }, - provenance: { role: 'inject', label: 'compact' }, - form: null, -} as ConversationNode - -const abandoned = { - kind: 'assistant', - seq: 20, - time: 20, - turn: 1, - step: 1, - blocks: [{ kind: 'text', text: 'abandoned' }], -} as ConversationNode - -const current = { - kind: 'user', - seq: 110, - time: 110, - content: [{ type: 'text', text: 'rewound' }], - source: { kind: 'plugin', plugin: 'rewind' }, -} as ConversationNode - -function interruptedTool(callId: string): ConversationNode { - return { - kind: 'tool-result', - seq: 19.2, - time: 20, - callId, - call: { name: 'parallel', argsRaw: '{}' }, - callTime: 10, - content: [], - isError: true, - error: { name: 'Interrupted', code: 'interrupted' }, - callView: null, - resultView: null, - subCalls: [], - } -} - -function request( - purpose: RequestView['purpose'], - startSeq: number, - resultSeq?: number, - replacementSeq?: number, -): RequestView { - const base = { - startSeq, - startedAt: startSeq, - completedAt: startSeq + 1, - status: 'complete' as const, - ...(resultSeq === undefined ? {} : { resultSeq }), - } - return purpose === 'assistant' - ? { ...base, purpose, turn: 1, step: 1 } - : { - ...base, - purpose, - turn: 1, - step: 0, - ...(replacementSeq === undefined ? {} : { replacementSeq }), - } -} - -describe('trajectory context branches', () => { - it('inherits nodes and requests by retained surface position rather than seq cutoff', () => { - const contexts: ConversationContext[] = [ - { id: 0, nodes: [checkpoint, abandoned] }, - { - id: 1, - parentId: 0, - origin: 'rewind', - originSeq: 110, - nodes: [checkpoint, current], - }, - ] - const branches = deriveTrajectoryContextBranches(contexts) - const successor = branches[1]! - - expect(successor.key).toBe('rewind:110') - expect(successor.nodes.map(node => node.seq)).toEqual([110]) - expect(trajectoryBranchContainsRequest( - successor, - request('assistant', 10, 20), - )).toBe(false) - expect(trajectoryBranchContainsRequest( - successor, - request('compaction', 90, 95, 100), - )).toBe(true) - expect(trajectoryBranchContainsRequest( - successor, - request('assistant', 111), - )).toBe(true) - }) - - it('keeps branch identity when prepended generations shift local ids', () => { - const branch = (id: number) => deriveTrajectoryContextBranches([{ - id, - origin: 'rewind', - originSeq: 110, - nodes: [current], - }])[0] - - expect(branch(1)?.key).toBe(branch(9)?.key) - }) - - it('retains parallel tool interruptions that share one closing boundary', () => { - const branch = deriveTrajectoryContextBranches([{ - id: 0, - nodes: [interruptedTool('call-a'), interruptedTool('call-b')], - }])[0] - - expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined)) - .toEqual(['call-a', 'call-b']) - }) -}) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts new file mode 100644 index 0000000000..f61f90c02c --- /dev/null +++ b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts @@ -0,0 +1,276 @@ +import type { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import type { + ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts' +import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts' +import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' +import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts' +import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts' +import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts' +import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts' + +const DEFINITIONS: ConversationNodeDefinition[] = [] +const registrationContext = { + conversationEvents: { + register: (definition: ConversationNodeDefinition) => { + DEFINITIONS.push(definition) + return () => {} + }, + }, +} as unknown as Context + +registerTrajectoryMessageDefinitions(registrationContext) +registerTrajectoryRequestHeaderDefinition(registrationContext) +registerTrajectoryAssistantDefinition(registrationContext) +registerTrajectoryToolDefinition(registrationContext) +registerTrajectoryCompactionDefinitions(registrationContext) + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return DEFINITIONS + } + + fallbackEntry(): undefined { + return undefined + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [trajectoryViewDefinition] + } +} + +function at( + seq: number, + type: string, + data: unknown, + extra: Record = {}, +): ConversationEventInput { + return { + event: { + seq, + time: 1_700_000_000_000 + seq, + type, + data, + ...extra, + } as unknown as ConversationEventInput['event'], + view: undefined, + } +} + +function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler { + const value = new ConversationNodeAssembler( + new TestEventDefinitions(), + new TestViewDefinitions(), + ) + value.replaceWindow(events, false) + value.flush() + return value +} + +function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot { + const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined + if (current === undefined) throw new Error('trajectory view was not registered') + return current +} + +function assistantMessage(id: string, text: string) { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'test', model: 'test' }, + } +} + +describe('Trajectory conversation Definitions', () => { + it('assembles streaming usage, preserves retry facts, and materializes interruption', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'first attempt' }, + }), + at(4, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } }, + }), + ]) + + expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }]) + expect(snapshot(value).requests).toMatchObject([{ + purpose: 'assistant', + status: 'running', + usage: { inputTokens: 10, outputTokens: 3 }, + }]) + + value.append(at(5, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'test', + mode: 'normal', + policyKey: 'test-normal', + retry: 1, + maxRetries: 2, + delayMs: 25, + failure: { code: 'TRANSPORT', message: 'temporary failure' }, + })) + value.append(at(6, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'second attempt' }, + })) + value.append(at(7, 'step/end', { turn: 1, step: 1 })) + value.flush() + + const settled = snapshot(value) + expect(settled.partial).toBeNull() + expect(settled.eventNodes).toMatchObject([{ + kind: 'assistant', + seq: 6.1, + interrupted: true, + blocks: [{ kind: 'text', text: 'second attempt' }], + }]) + expect(settled.requests).toMatchObject([{ + purpose: 'assistant', + status: 'error', + retry: 1, + maxRetries: 2, + retryDelayMs: 25, + usage: { inputTokens: 10, outputTokens: 3 }, + }]) + }) + + it('keeps parallel interrupted roots and nests Code Dispatch results', () => { + const current = snapshot(assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { + turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}', + }), + at(4, 'tool/call', { + turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}', + }), + at(5, 'tool/code-dispatch-start', { + rootCallId: 'root-a', + parentCallId: 'root-a', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + }), + at(6, 'tool/code-dispatch', { + rootCallId: 'root-a', + parentCallId: 'root-a', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + content: [{ type: 'text', text: 'contents' }], + }), + at(7, 'step/end', { turn: 1, step: 1 }), + ])) + + const tools = current.eventNodes.filter(node => node.kind === 'tool-result') + expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b']) + expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{ + kind: 'tool-result', + callId: 'child', + call: { name: 'read' }, + }]) + }) + + it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => { + const current = snapshot(assembler([ + at(1, 'compact/start', { compactionId: 'complete', turn: null }), + at(2, 'compact/summary', { + compactionId: 'complete', + turn: null, + summary: 'summary', + provider: 'test', + model: 'test', + maxTokens: 100, + usage: { inputTokens: 20, outputTokens: 5 }, + }), + at(3, 'user/message', { + id: 'checkpoint', + role: 'user', + content: [{ type: 'text', text: 'summary checkpoint' }], + source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' }, + }), + at(4, 'compact/end', { compactionId: 'complete', turn: null }), + at(5, 'compact/start', { compactionId: 'orphan', turn: null }), + at(6, 'session/end-seed', {}), + ])) + + expect(current.requests).toMatchObject([ + { + purpose: 'compaction', + startSeq: 1, + status: 'complete', + resultSeq: 2, + replacementSeq: 3, + summary: 'summary', + }, + { + purpose: 'compaction', + startSeq: 5, + status: 'error', + completedAt: 1_700_000_000_006, + }, + ]) + }) + + it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => { + const current = snapshot(assembler([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(3, 'user/message', { + id: 'm1', + role: 'user', + content: [{ type: 'text', text: 'steer here' }], + source: { kind: 'user' }, + }), + at(4, 'turn/start', { turn: 1 }), + at(5, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'test', model: 'test' }, + system: 'system prompt', + tools: [], + }, + }), + at(6, 'step/start', { turn: 1, step: 1 }), + at(7, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-1', 'first'), + }), + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'assistant/message', { + turn: 1, + step: 2, + message: assistantMessage('assistant-2', 'second'), + }), + ])) + + expect(current.eventNodes.find(node => node.seq === 3)?.kind).toBe('steering') + expect(current.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['system prompt', 'system prompt']) + expect(current.requests.map(request => request.purpose === 'assistant' + ? request.promptChange?.kind + : undefined)).toEqual(['initial', undefined]) + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 2439fb2de5..88c7f41ccf 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -75,10 +75,8 @@ function historySnapshot( ): ConversationSnapshot { const trajectory: TrajectorySnapshot = { eventNodes: nodes, - contexts: [{ id: 0, nodes }], requests: [], callSchemas: new Map(), - interruptedNodes: [], partial: null, runningCalls: [], ...inspection, @@ -191,7 +189,7 @@ async function bench(snapshot = historySnapshot(NODES)) { { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadOlder } + return { ctx, slots, fiber, loadOlder, sessionStore } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -294,8 +292,16 @@ describe('plugin registration', () => { it('fiber disposal removes the tab and leaves chat standing', async () => { const b = await bench() + const events = b.ctx.get('conversationEvents') as ConversationEventRegistry + const views = b.ctx.get('conversationViews') as ConversationViewRegistry + expect(events.entries().length).toBeGreaterThan(0) + expect(views.entries()).toHaveLength(1) + await b.fiber.dispose() + expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) + expect(events.entries()).toEqual([]) + expect(views.entries()).toEqual([]) }) it('shares one browser-wide duration preference across session injections', async () => { @@ -315,6 +321,23 @@ describe('plugin registration', () => { expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true') expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull() }) + + it('reports whether loading older history changed the Trajectory snapshot', async () => { + const b = await bench() + const entry = b.slots.entries('conversation.view') + .find(candidate => candidate.options.id === 'trajectory') + const injectEntry = entry!.inject as unknown as ( + sessionId: SessionId, + ) => TrajectoryViewInjected + const injected = injectEntry(SID) + + expect(await injected.loadOlder()).toBe(false) + + b.loadOlder.mockImplementationOnce(async () => { + b.sessionStore.set(historySnapshot([...NODES])) + }) + expect(await injected.loadOlder()).toBe(true) + }) }) describe('tab switching in ConversationRoot', () => { @@ -1083,7 +1106,7 @@ describe('timeline projection', () => { }) }) -describe('TrajectoryView branches', () => { +describe('TrajectoryView state', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() const commonProps = { @@ -1116,109 +1139,6 @@ describe('TrajectoryView branches', () => { .toBe('true') }) - it('renders only the selected rewind branch while retaining session-global requests', () => { - const retained = { - kind: 'user', - seq: 1, - time: 1_000, - content: [{ type: 'text', text: 'retained user' }], - source: null, - } as unknown as ConversationSnapshot['nodes'][number] - const abandoned = { - kind: 'assistant', - seq: 3, - time: 3_000, - turn: 1, - step: 1, - blocks: [{ kind: 'text', text: 'abandoned response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const current = { - kind: 'assistant', - seq: 5, - time: 5_000, - turn: 2, - step: 1, - blocks: [{ kind: 'text', text: 'current response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const request = (startSeq: number, turn: number): RequestView => ({ - purpose: 'assistant', - startSeq, - turn, - step: 1, - startedAt: startSeq * 1_000, - completedAt: startSeq * 1_000 + 100, - status: 'complete', - }) - const store = createSnapshotStore(historySnapshot( - [retained, abandoned, current], - { - eventNodes: [retained, abandoned, current], - contexts: [ - { id: 0, nodes: [retained, abandoned] }, - { - id: 1, - parentId: 0, - origin: 'rewind' as const, - originSeq: 4, - nodes: [retained, current], - }, - ], - requests: [request(2, 1), request(4, 2)], - callSchemas: new Map(), - }, - )) - - const view = render( - Promise.resolve(false))} - />, - ) - - expect(screen.queryByText('abandoned response')).toBeNull() - expect(screen.getByText('current response')).toBeTruthy() - expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy() - expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0) - }) - - it('does not remount the ledger when prepending shifts a rewind generation id', () => { - const current = { - kind: 'assistant', - seq: 5, - time: 5_000, - turn: 2, - step: 1, - blocks: [{ kind: 'text', text: 'stable rewind response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const snapshot = (id: number) => historySnapshot([current], { - contexts: [{ - id, - origin: 'rewind' as const, - originSeq: 4, - nodes: [current], - }], - }) - const store = createSnapshotStore(snapshot(1)) - render( - Promise.resolve(false))} - />, - ) - const row = screen.getByRole('row', { name: /stable rewind response/ }) - fireEvent.click(row) - expect(row.getAttribute('aria-selected')).toBe('true') - - act(() => { store.set(snapshot(2)) }) - - expect(screen.getByRole('row', { name: /stable rewind response/ }) - .getAttribute('aria-selected')).toBe('true') - }) - it('keeps ledger and timeline selection on the same event after prepend', () => { const older = { kind: 'user', seq: 1, time: 1_000, @@ -1249,46 +1169,6 @@ describe('TrajectoryView branches', () => { )).toBeTruthy() }) - it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => { - const retained = { - kind: 'user', seq: 1, time: 1_000, - content: [{ type: 'text', text: 'stop the task' }], source: null, - } as unknown as ConversationSnapshot['nodes'][number] - const interruptedAssistant = { - kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1, - blocks: [{ kind: 'text', text: 'partial response retained' }], - interrupted: true, - } as unknown as ConversationSnapshot['nodes'][number] - const interruptedTool = { - kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call', - call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900, - content: [], isError: true, - error: { name: 'Interrupted', code: 'interrupted' }, - callView: null, resultView: null, - } as unknown as ConversationSnapshot['nodes'][number] - const store = createSnapshotStore(historySnapshot( - [retained], - { - eventNodes: [retained], - contexts: [{ id: 0, nodes: [retained] }], - requests: [], - callSchemas: new Map(), - interruptedNodes: [interruptedAssistant, interruptedTool], - }, - )) - - render( - Promise.resolve(false))} - />, - ) - - expect(screen.getByText('partial response retained')).toBeTruthy() - expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy() - }) }) describe('node half', () => { diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index f525474d9d..5feffced67 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -20,6 +20,15 @@ { "path": "../runtime" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../compact/compact" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40f2643417..771706fb80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2813,6 +2813,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2825,9 +2828,15 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools '@types/react': specifier: ~18.3.1 version: 18.3.31 From 556b11ef56438979f4d8e3ba70cbe8200a325239 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:42:12 +0800 Subject: [PATCH 09/17] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 11 +++++++---- docs/module-graph.zh.md | 11 +++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 954f038557..1f864cf46d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 3efb73d075f3d5d7a8bae990fc2f524dc710bcb7 -module-graph.zh.md: df3b9b38497893471b2613c0c95da409dda0262b +module-graph.md: 2218d79e28e835ab96abce96eaf92bbae25e2182 +module-graph.zh.md: 276b70d69c2898d74ac6897e398b02a8944fd503 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3efb73d075..2218d79e28 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -399,9 +399,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -893,6 +890,12 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_compact + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1295,7 +1298,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1400,6 +1402,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index df3b9b3849..276b70d69c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -401,9 +401,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -895,6 +892,12 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_compact + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1297,7 +1300,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1402,6 +1404,7 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | From cc25bceec8c9a6292fe598948f956239fb1ab8cc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:13:21 +0800 Subject: [PATCH 10/17] fix(ui-trajectory): simplify terminal contribution branch --- .../src/client/trajectory-snapshot-builder.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index a7f2de6be4..d10979f75f 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -230,13 +230,11 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< boundaries.push({ seq: data.seq, time: data.time }) continue } - if (data.kind === 'turn-end') { - turnEndings.push({ - turn: data.turn, - time: data.time, - ...(data.error === undefined ? {} : { error: data.error }), - }) - } + turnEndings.push({ + turn: data.turn, + time: data.time, + ...(data.error === undefined ? {} : { error: data.error }), + }) } requests.sort((left, right) => left.startSeq - right.startSeq) From 58ea69f64f84bc92d05da3aeec8f41b726173e79 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:23:14 +0800 Subject: [PATCH 11/17] build(ui-trajectory): use rescoped cordis package --- .../ui-trajectory/src/client/trajectory-assistant-definition.ts | 2 +- .../src/client/trajectory-compaction-definition.ts | 2 +- .../ui-trajectory/src/client/trajectory-message-definitions.ts | 2 +- .../src/client/trajectory-request-header-definition.ts | 2 +- .../ui-trajectory/src/client/trajectory-snapshot-builder.ts | 2 +- .../ui-trajectory/src/client/trajectory-tool-definition.ts | 2 +- .../client/ui-trajectory/tests/conversation-definitions.spec.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 717b4855d7..16b61f6d53 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView, diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts index de6d2af21b..a822e4e5bb 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeDefinition, RequestView, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index c8861c85a7..4139a318db 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, SteeringMessageNode, UserMessageNode, diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index 4d8a0c9006..20a4d437e9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, RequestPromptChange, diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index d10979f75f..dcc5f2edbc 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AssistantMessageNode, ConversationNode, ConversationPromptSnapshot, ConversationViewBuilder, ConversationViewDefinition, RequestView, diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 201ac15d72..7e069dd912 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, ToolCallBlock, ToolResultNode, diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts index f61f90c02c..631d283f21 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, From e27dba845760211d7f8280cc02134e531dc778b7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:51:18 +0800 Subject: [PATCH 12/17] perf(ui-trajectory): defer trajectory text processing --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 2 + ...09-client-conversation-node-assembly.zh.md | 2 + packages/client/ui-primitives/src/Tooltip.tsx | 13 +- .../ui-primitives/tests/tooltip.spec.tsx | 21 +++ .../src/client/TrajectoryTable.tsx | 134 +++++++++++++----- .../src/client/TrajectoryTimeline.tsx | 2 +- .../src/client/TrajectoryView.tsx | 125 +++++++--------- .../client/ui-trajectory/src/client/layout.ts | 121 +++++++++------- .../src/client/trajectory-preview.ts | 20 +++ .../src/client/trajectory-record.ts | 6 +- .../src/client/trajectory-search-index.ts | 133 +++++++++++++++++ 12 files changed, 415 insertions(+), 168 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/trajectory-preview.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-search-index.ts diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 1dec922826..3d569b7ce2 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 1d5fd20bfa8c3b370f736937d54a668ca7f19ca3 -2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e +2026-08-09-client-conversation-node-assembly.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526 +2026-08-09-client-conversation-node-assembly.zh.md: 2ddb14c35b3ac5aeba5b00e7d56b3a4e69a97adb diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 1d5fd20bfa..3b02fde8b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -330,6 +330,8 @@ The concrete Tool renderer remains governed by the [`ui-tool ownership decision` Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts. +Trajectory stage/layout processing retains raw summary sources and structural data without parsing Markdown. A stable Record presentation in the Table memoizes each one-line summary by content and shares the result across body text, title, and aria-label; Detail renders only the selected record. Timeline timing labels invoke their formatters only after the delayed Tooltip opens. Search owns an independent per-view `TrajectorySearchIndex` keyed by stable Record identity with each source signature and normalized text. The initial window is indexed immediately, and a three-second throttle commits later new or changed Records in batches. Queries read only the latest committed index version, so a prepended page enters results atomically with the next batch; neither prepend nor append reparses unchanged historical Markdown. Display caching and search indexing do not share lifecycles. + ## Runtime and render path ```text diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 6f0acc4489..2ddb14c35b 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -330,6 +330,8 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。 +Trajectory 的 stage/layout 只保留原始摘要来源和结构数据,不解析 Markdown。Table 的稳定 Record presentation 按内容 memo 单行摘要,并把同一结果用于正文、title 与 aria-label;Detail 只渲染当前选中记录。Timeline 的时序标签只在延迟 Tooltip 实际打开后执行格式化。搜索拥有独立的 per-view `TrajectorySearchIndex`,按稳定 Record identity 保存来源签名和标准化文本;初始窗口立即建立索引,后续新增或变化的 Record 由三秒 throttle 批量提交。查询只读取最近一次提交的索引版本,分页的新一页随下一批一次性进入结果;prepend 与 append 都不会重复解析未变化的历史 Markdown。展示缓存与搜索索引互不借用生命周期。 + ## Runtime and render path ```text diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 449d4fe717..c1c1d1c5dc 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -24,9 +24,11 @@ interface AnchorProps { onBlur?: FocusEventHandler | undefined } +type TooltipLabel = string | (() => string) + /** * Attach a hover/focus tooltip to an anchor element. - * @param props.label - bubble text. + * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. * @param props.side - placement relative to the anchor (default 'right'). * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so @@ -34,7 +36,7 @@ interface AnchorProps { * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ -export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement }) { +export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -46,6 +48,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) const bubble = useRef(null) + const resolvedLabel = pos === null + ? null + : typeof label === 'function' ? label() : label // Horizontal viewport clamp: fixed positioning knows nothing about edges, so // a centered bubble near the right edge would clip. Each measurement resets // the base position before applying a direct style offset, allowing a shorter @@ -67,7 +72,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, clamp() window.addEventListener('resize', clamp) return () => { window.removeEventListener('resize', clamp) } - }, [label, pos]) + }, [pos, resolvedLabel]) const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -128,7 +133,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, })} {pos !== null && ( - {label} + {resolvedLabel} )} diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 72b33ce12c..b355b56f68 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('Tooltip', () => { + it('resolves lazy labels only after the bubble becomes visible', () => { + vi.useFakeTimers() + try { + const label = vi.fn(() => 'Timing details') + render( + + + , + ) + expect(label).not.toHaveBeenCalled() + fireEvent.mouseEnter(screen.getByText('anchor')) + act(() => { vi.advanceTimersByTime(499) }) + expect(label).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + expect(label).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + it('can delay pointer hover without delaying keyboard focus', () => { vi.useFakeTimers() try { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 9cef2dccfb..67ec1cd2d6 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -24,7 +24,8 @@ import { groupTrajectoryVirtualRows, trajectoryVirtualRecordKey, } from './trajectory-virtual-rows.ts' import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts' -import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryTurnModel } from './layout.ts' +import { trajectoryPreviewText } from './trajectory-preview.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -903,6 +904,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' + if (cell.previewMarkdown !== undefined) { + const preview = trajectoryPreviewText(cell.previewMarkdown) + if (cell.text === '') return preview + return preview === '' ? cell.text : `${cell.text} · ${preview}` + } if (cell.text !== '') return cell.text const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail @@ -912,6 +918,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string { return markdown === undefined ? '' : trajectoryPreviewText(markdown) } +function recordResultText(cell: TrajectoryCellProps): string | undefined { + return cell.resultPreviewMarkdown === undefined + ? cell.result + : trajectoryPreviewText(cell.resultPreviewMarkdown) +} + function toolCallTextParts( kind: TrajectoryCellKind, text: string, @@ -932,6 +944,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean { && cell.text === 'Tool call only' } +interface RecordPresentationValue { + displayText: string + listDisplayText: string + resultText: string | undefined + toolCallOnly: boolean + toolCallText: ToolCallTextParts | undefined +} + +function RecordPresentation({ + cell, + children, +}: { + cell: TrajectoryCellProps + children: (value: RecordPresentationValue) => ReactNode +}) { + const displayText = useMemo( + () => recordDisplayText(cell), + [ + cell.kind, cell.text, cell.previewMarkdown, + cell.inputDetail, cell.outputDetail, cell.thinkingDetail, + ], + ) + const resultText = useMemo( + () => recordResultText(cell), + [cell.result, cell.resultPreviewMarkdown], + ) + const toolCallOnly = isToolCallOnly(cell) + const toolCallText = toolCallTextParts(cell.kind, displayText) + const listDisplayText = toolCallOnly + ? '(tool call only)' + : toolCallText === undefined + ? displayText + : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') + return children({ + displayText, + listDisplayText, + resultText, + toolCallOnly, + toolCallText, + }) +} + +function RecordListText({ + displayText, + toolCallOnly, + toolCallText, +}: Pick) { + if (toolCallOnly) { + return (tool call only) + } + if (toolCallText === undefined) return displayText || '—' + return ( + <> + + {toolCallText.name || '—'} + + {toolCallText.args !== undefined && ( + + {toolCallText.args} + + )} + + ) +} + function MarkdownFragment({ text, rendered, @@ -2131,15 +2208,12 @@ export function TrajectoryTable({ /> )} - {renderedRecords.map(({ record, position, terminalRequestBoundary }) => { - const displayText = recordDisplayText(record.cell) - const toolCallOnly = isToolCallOnly(record.cell) - const toolCallText = toolCallTextParts(record.cell.kind, displayText) - const listDisplayText = toolCallOnly - ? '(tool call only)' - : toolCallText === undefined - ? displayText - : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') + {renderedRecords.map(({ record, position, terminalRequestBoundary }) => ( + + {({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => { const isCollapsedSummary = record.collapsedSummary !== undefined const isRequestOnly = record.cell.requestOnly === true const isInitialSystem = record.cell.kind === 'system' @@ -2169,7 +2243,6 @@ export function TrajectoryTable({ : activeTurn === record.turn return ( - - {toolCallOnly - ? (tool call only) - : toolCallText === undefined - ? listDisplayText || '—' - : ( - <> - - {toolCallText.name || '—'} - - {toolCallText.args !== undefined && ( - - {toolCallText.args} - - )} - - )} + + - {record.cell.result !== undefined && ( + {resultText !== undefined && ( - - {record.cell.result} + {resultText} )} @@ -2387,7 +2449,9 @@ export function TrajectoryTable({ ) - })} + }} + + ))} {virtualBottom > 0 && ( timelineTooltipLabel(span.kind, detail)} side="bottom" delayMs={TIMELINE_TOOLTIP_DELAY_MS} > diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 5651e620e5..2b2e50fc65 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,6 +1,6 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { @@ -24,11 +24,13 @@ import { type TrajectoryTimeRange, } from './timeline.ts' import { trajectoryRecordId } from './trajectory-record.ts' +import { TrajectorySearchIndex } from './trajectory-search-index.ts' import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' import css from './views.module.css' const EMPTY_TURN_IDS: ReadonlySet = new Set() const EMPTY_RECORD_IDS: ReadonlySet = new Set() +const SEARCH_INDEX_THROTTLE_MS = 3_000 function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number { let last = 0 @@ -115,70 +117,6 @@ function addUsage( } } -function searchableJson(value: unknown): string { - if (value === undefined) return '' - try { - return JSON.stringify(value) - } catch { - return '' - } -} - -function searchMatches( - turns: ReturnType, - query: string, -): ReadonlySet | null { - const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) - if (terms.length === 0) return null - const matches = new Set() - for (const turn of turns) { - for (const group of turn.groups) { - for (const cell of group.cells) { - if (cell.requestOnly === true) continue - const blocks = [ - ...(cell.sourceBlocks ?? []), - ...(cell.outputBlocks ?? []), - ] - const text = [ - turn.turn === null ? 'between turns' : `turn ${turn.turn}`, - group.title, - cell.kind, - cell.kind === 'message' ? 'assistant' : undefined, - cell.text, - cell.inputDetail, - cell.outputDetail, - cell.thinkingDetail, - cell.schemaDetail, - cell.result, - cell.callId, - ...blocks.flatMap(block => [ - block.type, - block.content, - block.callId, - block.toolName, - block.imageAlt, - ]), - searchableJson(cell.messageSource), - searchableJson(cell.promptDetail), - searchableJson(cell.previousPromptDetail), - ].filter((value): value is string => typeof value === 'string') - .join('\n') - .toLocaleLowerCase() - if (terms.every(term => text.includes(term))) matches.add(cell.index) - } - } - } - return matches -} - -function mergeSearchMatches( - finalized: ReadonlySet | null, - partial: ReadonlySet | null, -): ReadonlySet | null { - if (finalized === null || partial === null) return null - return new Set([...finalized, ...partial]) -} - export function TrajectoryView({ useSession, useDuration, loadOlder, setActualDuration, inspect, onInspectDone, @@ -190,6 +128,10 @@ export function TrajectoryView({ const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [searchIndex] = useState(() => new TrajectorySearchIndex()) + const [searchIndexRevision, setSearchIndexRevision] = useState(0) + const searchIndexTimer = useRef | null>(null) + const searchIndexInitialized = useRef(false) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState(null) const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ readonly index: number @@ -340,28 +282,59 @@ export function TrajectoryView({ const timelineMode: TrajectoryTimelineMode = actualDuration ? actualTime ? 'actual' : 'duration' : actualTime ? 'time' : 'sequence' - const finalizedSearchMatches = useMemo( - () => searchMatches(finalized.turns, searchQuery), - [finalized, searchQuery], - ) const partialSearchTurns = useMemo( () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex), [finalized.lastIndex, partial], ) + const searchLayouts = useMemo( + () => [finalized.turns, partialSearchTurns] as const, + [finalized, partialSearchTurns], + ) + const latestSearchLayouts = useRef(searchLayouts) + latestSearchLayouts.current = searchLayouts + useEffect(() => { + if (!searchIndexInitialized.current) { + searchIndexInitialized.current = true + if (searchIndex.update(searchLayouts)) { + setSearchIndexRevision(revision => revision + 1) + } + return + } + if (searchIndexTimer.current !== null) return + searchIndexTimer.current = setTimeout(() => { + searchIndexTimer.current = null + if (searchIndex.update(latestSearchLayouts.current)) { + setSearchIndexRevision(revision => revision + 1) + } + }, SEARCH_INDEX_THROTTLE_MS) + }, [searchIndex, searchLayouts]) + useEffect(() => () => { + if (searchIndexTimer.current !== null) clearTimeout(searchIndexTimer.current) + }, []) const streamingCells = useMemo( () => partialSearchTurns.flatMap(turn => turn.groups.flatMap(group => group.cells), ), [partialSearchTurns], ) - const partialSearchMatches = useMemo( - () => searchMatches(partialSearchTurns, searchQuery), - [partialSearchTurns, searchQuery], - ) - const searchMatchIndexes = useMemo( - () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), - [finalizedSearchMatches, partialSearchMatches], + const searchMatchRecordIds = useMemo( + () => searchIndex.search(searchQuery), + [searchIndex, searchIndexRevision, searchQuery], ) + const searchMatchIndexes = useMemo(() => { + if (searchMatchRecordIds === null) return null + const indexes = new Set() + for (const turns of searchLayouts) { + for (const turn of turns) { + for (const group of turn.groups) { + for (const cell of group.cells) { + if (searchMatchRecordIds.has(trajectoryRecordId(cell))) indexes.add(cell.index) + } + } + } + } + return indexes + }, [searchLayouts, searchMatchRecordIds]) const timelineRange = timelineSelection const timelineFocusIndexes = useMemo( () => timelineRange === null diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 0d7a8c9e07..3a118aadfb 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,7 +12,6 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -70,9 +69,6 @@ interface TurnBucket { type AssistantRequestView = Extract type CompactionRequestView = Extract -const PREVIEW_SOURCE_CHARACTERS = 2_048 -const PREVIEW_OUTPUT_CHARACTERS = 512 - type InputNode = Extract< ConversationSnapshot['nodes'][number], { kind: 'user' | 'context' } @@ -110,10 +106,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number { function inputCellDetail(node: InputNode): Pick< TrajectoryCellProps, - 'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt' + | 'text' + | 'previewMarkdown' + | 'sourceSeq' + | 'messageSource' + | 'inputDetail' + | 'sourceBlocks' + | 'timeSeconds' + | 'startedAt' > { + const previewMarkdown = previewContent(node.content) return { - text: summarizeContent(node.content), + text: '', + ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, inputDetail: detailContent(node.content), @@ -293,7 +298,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T ? request.error ?? 'Compaction failed' : request.summary === undefined ? 'Context compacted' - : summarizeContent(request.summary), + : '', + ...(request.status === 'complete' && request.summary !== undefined + ? previewContentProperty(request.summary) + : {}), sourceSeq: request.startSeq, ...(request.summary === undefined ? {} @@ -377,6 +385,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!emittedCallIds.has(node.callId)) { const toolName = node.call?.name + const resultPreview = summarizeResult(node) const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), @@ -386,13 +395,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'tool', sourceSeq: node.seq, - text: node.call !== null + ...(node.call !== null ? summarizeCall(node.call.name, node.call.argsRaw) - : summarizeResult(node), + : resultAsText(resultPreview)), ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), outputDetail: detailResult(node), outputBlocks: node.content.map(block => sourceBlock(block)), - result: summarizeResult(node), + ...resultPreview, callId: node.callId, isError: node.isError, timeSeconds: durationSeconds(node.time, node.callTime), @@ -440,7 +449,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T cell: { index: ++index, kind: 'tool', - text: summarizeCall(call.name, call.argsRaw), + ...summarizeCall(call.name, call.argsRaw), inputDetail: call.argsRaw, callId: call.callId, timeSeconds: null, @@ -650,11 +659,14 @@ function expandAssistant( recordId: `assistant\u0000${node.turn}\u0000${node.step}`, kind: 'message', sourceSeq: node.seq, - text: messageText !== '' - ? summarizeText(messageText) + text: messageText !== '' || thinkingText !== '' + ? '' + : summarizeAssistantActivity(node.blocks), + ...(messageText !== '' + ? { previewMarkdown: messageText } : thinkingText !== '' - ? summarizeText(thinkingText) - : summarizeAssistantActivity(node.blocks), + ? { previewMarkdown: thinkingText } + : {}), ...(messageText !== '' ? { outputDetail: messageText } : {}), ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}), sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)), @@ -681,6 +693,7 @@ function expandAssistant( : durationSeconds(result.time, result.callTime) const callAbs = finiteTime(callStarts.get(block.callId)) const call = calls.get(block.callId) + const resultPreview = result === undefined ? undefined : summarizeResult(result) out.push({ absTime: callAbs, toolName: block.name, @@ -688,14 +701,14 @@ function expandAssistant( ...(call === undefined ? {} : { subCalls: call.subCalls }), cell: { index: ++index, kind: 'tool', - text: summarizeCall(block.name, block.argsRaw), + ...summarizeCall(block.name, block.argsRaw), inputDetail: block.argsRaw, callId: block.callId, ...(result !== undefined ? { outputDetail: detailResult(result), outputBlocks: result.content.map(block => sourceBlock(block)), - result: summarizeResult(result), + ...resultPreview, isError: result.isError, } : {}), @@ -919,6 +932,7 @@ function expandSubCalls( let index = startIndex for (const sub of subs) { const settled = 'kind' in sub + const resultPreview = settled ? summarizeResult(sub) : undefined const laid: LaidCell = { absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), toolName: settled ? sub.call?.name ?? sub.callId : sub.name, @@ -927,9 +941,11 @@ function expandSubCalls( index: ++index, kind: 'subtool', callId: sub.callId, - text: settled - ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) - : summarizeCall(sub.name, sub.argsRaw), + ...(settled + ? (sub.call !== null + ? summarizeCall(sub.call.name, sub.call.argsRaw) + : resultAsText(resultPreview)) + : summarizeCall(sub.name, sub.argsRaw)), ...(settled ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) : { inputDetail: sub.argsRaw }), @@ -937,7 +953,7 @@ function expandSubCalls( ? { outputDetail: detailResult(sub), outputBlocks: sub.content.map(block => sourceBlock(block)), - result: summarizeResult(sub), + ...resultPreview, isError: sub.isError, } : {}), @@ -958,22 +974,39 @@ function expandSubCalls( return out } -function summarizeCall(name: string, argsRaw: string): string { - const args = trajectoryPreviewText(argsRaw) - if (args === '') return name - return `${name} · ${args}` +function summarizeCall( + name: string, + argsRaw: string, +): Pick { + return { + text: name, + ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }), + } } -function summarizeResult(node: ToolResultNode): string { +function summarizeResult( + node: ToolResultNode, +): Pick { if (node.isError) { - return node.error?.code ?? 'error' + return { result: node.error?.code ?? 'error' } } for (const block of node.content) { if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { - return summarizeText(block.text) + return { result: '', resultPreviewMarkdown: block.text } } } - return 'No output' + return { result: 'No output' } +} + +function resultAsText( + result: Pick | undefined, +): Pick { + return { + text: result?.result ?? '', + ...(result?.resultPreviewMarkdown === undefined + ? {} + : { previewMarkdown: result.resultPreviewMarkdown }), + } } function detailResult(node: ToolResultNode): string { @@ -1009,28 +1042,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s .join('\n') } -function summarizeContent(content: readonly { type: string; text?: string }[]): string { +function previewContent( + content: readonly { type: string; text?: string }[], +): string | undefined { for (const block of content) { - if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) + if (block.type === 'text' && typeof block.text === 'string') return block.text } - return '' + return undefined } -function summarizeText(text: string): string { - return trajectoryPreviewText(text) -} - -/** - * Build a bounded one-line ledger preview without parsing the complete Markdown document. - * Full source remains on the cell for the inspector. - * @param text - Untrusted message, reasoning, payload, or result text. - * @returns A compact preview capped independently from the retained source. - */ -export function trajectoryPreviewText(text: string): string { - const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS) - const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim() - const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd() - return source.length < text.length || preview.length < compact.length - ? `${preview}…` - : preview +function previewContentProperty( + content: readonly { type: string; text?: string }[], +): Pick { + const previewMarkdown = previewContent(content) + return previewMarkdown === undefined ? {} : { previewMarkdown } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-preview.ts b/packages/client/ui-trajectory/src/client/trajectory-preview.ts new file mode 100644 index 0000000000..840fc2381e --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-preview.ts @@ -0,0 +1,20 @@ +/** Bounded Markdown-to-text projection shared by trajectory consumers. */ + +import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives' + +const PREVIEW_SOURCE_CHARACTERS = 2_048 +const PREVIEW_OUTPUT_CHARACTERS = 512 + +/** + * Build a bounded one-line preview without parsing the complete Markdown document. + * @param text - Untrusted message, reasoning, payload, or result text. + * @returns A compact preview capped independently from the retained source. + */ +export function trajectoryPreviewText(text: string): string { + const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS) + const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim() + const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd() + return source.length < text.length || preview.length < compact.length + ? `${preview}…` + : preview +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 2c3f2a1a83..38101f8233 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -40,8 +40,10 @@ export interface TrajectoryCellProps extends HTMLAttributes { /** Projection-stable identity when no single source event owns the record lifecycle. */ recordId?: string kind: TrajectoryCellKind - /** Single-line summary; CSS ellipsis when it overflows. */ + /** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */ text: string + /** Raw Markdown source converted into the single-line summary at its consumer. */ + previewMarkdown?: string /** Whether this user record opens a new model turn. */ opensTurn?: boolean /** Source session-event seq for cross-record navigation. */ @@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes { assistantMetrics?: AssistantMetricDetail /** Tool-only result summary paired with the call in the same record. */ result?: string + /** Raw Markdown source converted into the tool-result summary at its consumer. */ + resultPreviewMarkdown?: string /** Tool call id used to link message source blocks to tool records. */ callId?: string /** Tool-only result failure state. */ diff --git a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts new file mode 100644 index 0000000000..889fbff7e6 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts @@ -0,0 +1,133 @@ +/** Incremental full-text index for the trajectory ledger. */ + +import type { TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryCellProps } from './trajectory-record.ts' +import { trajectoryRecordId } from './trajectory-record.ts' +import { trajectoryPreviewText } from './trajectory-preview.ts' + +interface SearchEntry { + readonly sources: readonly string[] + readonly text: string +} + +function searchableJson(value: unknown): string { + if (value === undefined) return '' + try { + return JSON.stringify(value) + } catch { + return '' + } +} + +function sameSources(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function markdownPreview(cell: TrajectoryCellProps): string { + if (cell.previewMarkdown === undefined) return '' + const preview = trajectoryPreviewText(cell.previewMarkdown) + if (cell.text === '') return preview + return preview === '' ? cell.text : `${cell.text} · ${preview}` +} + +function resultPreview(cell: TrajectoryCellProps): string { + return cell.resultPreviewMarkdown === undefined + ? cell.result ?? '' + : trajectoryPreviewText(cell.resultPreviewMarkdown) +} + +function recordSources( + turn: number | null, + group: string, + cell: TrajectoryCellProps, +): readonly string[] { + const blocks = [ + ...(cell.sourceBlocks ?? []), + ...(cell.outputBlocks ?? []), + ] + return [ + turn === null ? 'between turns' : `turn ${turn}`, + group, + cell.kind, + cell.kind === 'message' ? 'assistant' : '', + cell.text, + cell.previewMarkdown ?? '', + cell.inputDetail ?? '', + cell.outputDetail ?? '', + cell.thinkingDetail ?? '', + cell.schemaDetail ?? '', + cell.result ?? '', + cell.resultPreviewMarkdown ?? '', + cell.callId ?? '', + ...blocks.flatMap(block => [ + block.type, + block.content, + block.callId ?? '', + block.toolName ?? '', + block.imageAlt ?? '', + ]), + searchableJson(cell.messageSource), + searchableJson(cell.promptDetail), + searchableJson(cell.previousPromptDetail), + ] +} + +/** Session-view-local index that reparses Markdown only when one record's source changes. */ +export class TrajectorySearchIndex { + private readonly entries = new Map() + private layouts: readonly (readonly TrajectoryTurnModel[])[] | undefined + + /** + * Incrementally synchronize one or more current trajectory layout slices. + * @param layouts - Finalized and optional streaming layouts from the same view. + * @returns Whether the indexed layout version changed. + */ + update(layouts: readonly (readonly TrajectoryTurnModel[])[]): boolean { + if (this.layouts === layouts) return false + this.layouts = layouts + const seen = new Set() + for (const turns of layouts) { + for (const turn of turns) { + for (const group of turn.groups) { + for (const cell of group.cells) { + if (cell.requestOnly === true) continue + const id = trajectoryRecordId(cell) + const sources = recordSources(turn.turn, group.title, cell) + const previous = this.entries.get(id) + const entry = previous !== undefined && sameSources(previous.sources, sources) + ? previous + : { + sources, + text: [ + ...sources, + markdownPreview(cell), + resultPreview(cell), + ].join('\n').toLocaleLowerCase(), + } + this.entries.set(id, entry) + seen.add(id) + } + } + } + } + for (const id of this.entries.keys()) { + if (!seen.has(id)) this.entries.delete(id) + } + return true + } + + /** + * Match a query against the latest committed index version. + * @param query - Space-separated case-insensitive search terms. + * @returns Matching stable record identities, or `null` without a query. + */ + search(query: string): ReadonlySet | null { + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return null + const matches = new Set() + for (const [id, entry] of this.entries) { + if (terms.every(term => entry.text.includes(term))) matches.add(id) + } + return matches + } +} From 2695f31edb633a2a59ecc8fbd27162184137a219 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:01:14 +0800 Subject: [PATCH 13/17] test(ui-trajectory): assert deferred preview sources --- .../ui-trajectory/tests/layout.spec.tsx | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 4c49b07907..d04c95b46d 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -84,7 +84,10 @@ describe('deriveTrajectoryLayout', () => { input: 10, output: 20, think: 5, timeSeconds: 5, }) const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') - expect(tool?.text).toBe('bash · {"command":"ls"}') + expect(tool).toMatchObject({ + text: 'bash', + previewMarkdown: '{"command":"ls"}', + }) expect(tool?.timeSeconds).toBe(1.3) }) @@ -99,7 +102,10 @@ describe('deriveTrajectoryLayout', () => { }) expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2']) expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ - kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, + kind: 'tool', + text: 'bash', + previewMarkdown: '{"command":"pwd"}', + timeSeconds: null, }) }) @@ -132,7 +138,8 @@ describe('deriveTrajectoryLayout', () => { expect(streamed[1]?.groups[0]?.cells).toMatchObject([{ index: 2, kind: 'message', - text: 'streaming', + text: '', + previewMarkdown: 'streaming', timeSeconds: null, }]) expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined() @@ -222,8 +229,14 @@ describe('deriveTrajectoryLayout', () => { ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns.map(t => t.turn)).toEqual([1, 2]) - expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1']) - expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2']) + expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([ + 'first', + 'ok1', + ]) + expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([ + 'second', + 'ok2', + ]) }) it('places standalone compaction chronologically in its own between-turn section', () => { @@ -263,7 +276,8 @@ describe('deriveTrajectoryLayout', () => { cells: [{ kind: 'compacted', sourceSeq: 3, - text: 'standalone summary', + text: '', + previewMarkdown: 'standalone summary', }], }]) }) @@ -279,7 +293,7 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message).toMatchObject({ - text: '…', input: 11, output: 22, think: 3, + text: '', previewMarkdown: '…', input: 11, output: 22, think: 3, }) }) @@ -296,9 +310,8 @@ describe('deriveTrajectoryLayout', () => { const message = turns[0]?.groups.flatMap(group => group.cells) .find(cell => cell.kind === 'message') - expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true) - expect(message?.text.endsWith('…')).toBe(true) - expect(message?.text.length).toBeLessThanOrEqual(513) + expect(message?.text).toBe('') + expect(message?.previewMarkdown).toBe(thinking) expect(message?.thinkingDetail).toBe(thinking) }) @@ -331,7 +344,7 @@ describe('deriveTrajectoryLayout', () => { ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] - const message = cells.find(c => c.kind === 'message' && c.text === 'done') + const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done') // From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces. expect(message?.timeSeconds).toBe(0.5) // Context remains inspectable in trajectory; the Chat marker is not duplicated. @@ -394,7 +407,9 @@ describe('run_code sub-dispatch cells', () => { expect(cells[0]?.text).toBe('Tool call only') // Sequential indexes across the interleave; durations from the pair times. expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4]) - expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) + expect(cells[2]).toMatchObject({ + text: 'bash', previewMarkdown: '{"x":1}', timeSeconds: 1, + }) expect(cells[3]).toMatchObject({ timeSeconds: 0.5 }) }) @@ -405,7 +420,9 @@ describe('run_code sub-dispatch cells', () => { } const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] }) const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool') - expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null }) + expect(sub).toMatchObject({ + text: 'grep', previewMarkdown: '{"pattern":"x"}', timeSeconds: null, + }) }) it('recursively flattens nested child calls immediately after their parent', () => { From 98a4cc6569fc66c8d56e94175594102963235447 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:04:51 +0800 Subject: [PATCH 14/17] style(ui-trajectory): align presentation render tree --- .../src/client/TrajectoryTable.tsx | 432 +++++++++--------- .../client/ui-trajectory/src/client/layout.ts | 4 +- .../src/client/trajectory-search-index.ts | 14 +- 3 files changed, 225 insertions(+), 225 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 67ec1cd2d6..a1db84f50c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2214,241 +2214,241 @@ export function TrajectoryTable({ cell={record.cell} > {({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => { - const isCollapsedSummary = record.collapsedSummary !== undefined - const isRequestOnly = record.cell.requestOnly === true - const isInitialSystem = record.cell.kind === 'system' + const isCollapsedSummary = record.collapsedSummary !== undefined + const isRequestOnly = record.cell.requestOnly === true + const isInitialSystem = record.cell.kind === 'system' && record.cell.index === allRecords[0]?.cell.index - const request = record.groupStart + const request = record.groupStart && !isCollapsedSummary && (record.turn === null || !collapsedTurns.has(record.turn)) - ? requestNumbers.get(requestKey(record.turn, record.group)) - : undefined - const requestInfo = request === undefined - ? undefined - : sessionRequestNumbers?.find(candidate => candidate.number === request) - const requestStatus = requestInfo?.status + ? requestNumbers.get(requestKey(record.turn, record.group)) + : undefined + const requestInfo = request === undefined + ? undefined + : sessionRequestNumbers?.find(candidate => candidate.number === request) + const requestStatus = requestInfo?.status ?? (record.cell.isError === true ? 'error' : undefined) - const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 - const requestBoundaryStyle: RequestBoundaryStyle = { - '--request-boundary-offset': `${requestRunIndex * 8}px`, - } - const requestLabel = request === undefined - ? undefined - : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` - const requestSelected = request !== undefined + const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 + const requestBoundaryStyle: RequestBoundaryStyle = { + '--request-boundary-offset': `${requestRunIndex * 8}px`, + } + const requestLabel = request === undefined + ? undefined + : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` + const requestSelected = request !== undefined && selectedRequest?.turn === record.turn && selectedRequest.group === record.group - const sectionActive = record.turn === null - ? activeSection === record.section - : activeTurn === record.turn - return ( - { - if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + const sectionActive = record.turn === null + ? activeSection === record.section + : activeTurn === record.turn + return ( + { + if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + onToggleTurn(record.turn) + } else onToggleAssistant(trajectoryRecordId(record.cell)) + } + : () => { selectRecord(record.cell.index) }} + onDoubleClick={(event) => { + if (isCollapsedSummary || isRequestOnly) return + if (record.turn !== null && collapsedTurns.has(record.turn)) { + event.preventDefault() onToggleTurn(record.turn) - } else onToggleAssistant(trajectoryRecordId(record.cell)) - } - : () => { selectRecord(record.cell.index) }} - onDoubleClick={(event) => { - if (isCollapsedSummary || isRequestOnly) return - if (record.turn !== null && collapsedTurns.has(record.turn)) { - event.preventDefault() - onToggleTurn(record.turn) - return - } - if ( - record.cell.kind === 'message' + return + } + if ( + record.cell.kind === 'message' && assistantToolCalls(allRecords, record.cell.index).length > 0 - ) { - event.preventDefault() - onToggleAssistant(trajectoryRecordId(record.cell)) - return - } - if (!record.turnStart) return - if (record.turn === null) return - if (allRecords.filter(candidate => - candidate.turn === record.turn + ) { + event.preventDefault() + onToggleAssistant(trajectoryRecordId(record.cell)) + return + } + if (!record.turnStart) return + if (record.turn === null) return + if (allRecords.filter(candidate => + candidate.turn === record.turn && candidate.cell.requestOnly !== true && candidate.cell.kind !== 'system').length <= 1) return - event.preventDefault() - onToggleTurn(record.turn) - }} - onKeyDown={(event) => { - if (isRequestOnly) return - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - if (isCollapsedSummary) { - if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + event.preventDefault() onToggleTurn(record.turn) - } else onToggleAssistant(trajectoryRecordId(record.cell)) - return - } - selectRecord(record.cell.index) - }} - > - - {request !== undefined && ( -