Kaynağa Gözat

refactor(client): localize conversation projections

imccyu 2 hafta önce
ebeveyn
işleme
9f2f498e7c
27 değiştirilmiş dosya ile 476 ekleme ve 358 silme
  1. 16 2
      packages/client/connection/src/client/fixture.ts
  2. 0 3
      packages/client/ui-chat/src/client/chat/StatsLine.tsx
  3. 3 5
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  4. 0 4
      packages/client/ui-chat/src/client/contract/snapshot.ts
  5. 3 3
      packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
  6. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  7. 165 0
      packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts
  8. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/message.ts
  9. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/partial.ts
  10. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts
  11. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts
  12. 0 14
      packages/client/ui-chat/src/client/details/tool-node-reader.ts
  13. 1 4
      packages/client/ui-chat/src/client/index.ts
  14. 1 19
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  15. 30 2
      packages/client/ui-chat/tests/conversation.client.spec.ts
  16. BIN
      packages/client/ui-chat/tests/event-projection.client.spec.ts
  17. 2 107
      packages/client/ui-conversation/src/client/contract/context-provenance.ts
  18. 3 42
      packages/client/ui-conversation/src/client/contract/records.ts
  19. 0 70
      packages/client/ui-conversation/src/client/conversation/assistant-timing.ts
  20. 0 25
      packages/client/ui-conversation/src/client/conversation/failure-display.ts
  21. 0 20
      packages/client/ui-conversation/src/client/index.ts
  22. 12 0
      packages/client/ui-conversation/tests/context-meter.client.spec.tsx
  23. 0 23
      packages/client/ui-conversation/tests/failure-display.client.spec.ts
  24. 7 6
      packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts
  25. 154 0
      packages/client/ui-trajectory/src/client/trajectory-event-projection.ts
  26. 4 4
      packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts
  27. 70 0
      packages/client/ui-trajectory/tests/event-projection.client.spec.ts

+ 16 - 2
packages/client/connection/src/client/fixture.ts

@@ -4,13 +4,13 @@ import {
   createAssistantMessage,
   createToolResultMessage,
   createUserMessage,
-  isTokenDelta,
 } from '@deepseek-ai/dsh-llm/message'
 import { CallId, type MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type {
   AssistantMessage,
   ContentBlock,
   MessageSource,
+  StreamChunk,
   TokenUsage,
   ToolResultMessage,
   UserMessage,
@@ -40,6 +40,20 @@ import type {
 
 const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20
 
+/* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */
+function isFixtureTokenDelta(chunk: StreamChunk): boolean {
+  switch (chunk.type) {
+    case 'text-delta':
+    case 'reasoning-delta':
+      return chunk.text !== ''
+    case 'tool-call-delta':
+      return chunk.argumentsDelta !== '' || chunk.name !== undefined
+    default:
+      return false
+  }
+}
+/* jscpd:ignore-end */
+
 interface FixtureSessionSummary {
   readonly sessionId: SessionId
   updatedAt: number
@@ -1129,7 +1143,7 @@ function sessionStatsOf(log: readonly SessionEvent[]): {
         break
       case 'assistant/chunk':
         if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step
-          && openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) {
+          && openStep.firstTokenTime === null && isFixtureTokenDelta(event.data.chunk)) {
           openStep.firstTokenTime = event.time
         }
         break

+ 0 - 3
packages/client/ui-chat/src/client/chat/StatsLine.tsx

@@ -3,7 +3,6 @@
 // active conversation scrollport (see ConversationRoot data-conversation-scroll).
 
 import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { contextOccupancy } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
@@ -16,8 +15,6 @@ import { formatTokensPerSecond } from './message-chrome.ts'
 import { assistantStepReading } from '../contract/turn-metrics.ts'
 import css from './StatsLine.module.css'
 
-export { contextOccupancy }
-
 interface WindowStats {
   turns: number
   steps: number

+ 3 - 5
packages/client/ui-chat/src/client/contract/chat-nodes.ts

@@ -1,10 +1,8 @@
-import type {
-  ConversationLocation, ConversationViewNode,
-} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {
   AssistantBlock, AssistantMessageNode, CommandNode, CompactionSummaryNode,
-  ModelRetryNode, RunningToolCall, ToolCallBlock,
-} from './snapshot.ts'
+  ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall,
+  ToolCallBlock,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 /** Final Chat render unit produced by a Chat business Definition. */
 export interface ChatConversationViewNode extends ConversationViewNode {

+ 0 - 4
packages/client/ui-chat/src/client/contract/snapshot.ts

@@ -10,10 +10,6 @@ export type {
   ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
   UserMessageNode,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-export {
-  emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
-} from '@deepseek-ai/dsh-client-ui-conversation/client'
-
 /** Stable live per-key reader for Chat nodes. */
 export interface ChatNodeStore {
   /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */

+ 3 - 3
packages/client/ui-chat/src/client/conversation-nodes/assistant.ts

@@ -2,14 +2,14 @@ import type { Context } from '@deepseek-ai/cordis'
 import type {
   ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
 import type { AssistantBlock, AssistantMessageNode } from '../contract/snapshot.ts'
-import { toAssistantBlock, toAssistantBlocks } from '../contract/snapshot.ts'
-import { emptyAssistantBlock } from '../contract/snapshot.ts'
 import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
+import {
+  emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks,
+} from './event-projection.ts'
 
 declare module '../contract/chat-nodes.ts' {
   interface ChatNodeDataMap {

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -9,7 +9,7 @@ import type {
   ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationNode,
   LegacyConversationSlice, PartialAssistant, RunningToolCall,
 } from '../contract/snapshot.ts'
-import { sessionRecallLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { sessionRecallLabels } from './event-projection.ts'
 
 const EMPTY_KEYS: readonly string[] = []
 const EMPTY_TURNS: readonly number[] = []

+ 165 - 0
packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts

@@ -0,0 +1,165 @@
+/** Chat-owned conversion from durable Session events to Chat view data. */
+
+import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
+import type {
+  AssistantBlock, ContextProvenanceView, KnownContextForm,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
+
+/* jscpd:ignore-start -- Chat and Trajectory own independent event-to-view projections. */
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : null
+}
+
+function readString(record: Record<string, unknown>, key: string): string | null {
+  const value = record[key]
+  return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+function collect(source: Record<string, unknown>, member: string, field: string): string[] {
+  const list = source[member]
+  if (!Array.isArray(list)) return []
+  const seen: string[] = []
+  for (const entry of list) {
+    const record = asRecord(entry)
+    const value = record === null ? null : readString(record, field)
+    if (value !== null && !seen.includes(value)) seen.push(value)
+  }
+  return seen
+}
+
+function joined(names: string[]): string | null {
+  return names.length > 0 ? names.join(', ') : null
+}
+
+const KNOWN_FORMS: readonly KnownContextForm[] = [
+  'instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall',
+]
+
+/**
+ * Read the target-supported presentation form from a durable message source.
+ * @param source - Logged `user/message` source.
+ * @returns Supported form, or null for the opaque presentation.
+ */
+export function contextForm(source: unknown): KnownContextForm | null {
+  const record = asRecord(source)
+  const form = record === null ? null : readString(record, 'form')
+  return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
+    ? form as KnownContextForm
+    : null
+}
+
+/**
+ * Project a durable message source to the Chat row's role and producer label.
+ * @param source - Logged `user/message` source.
+ * @returns Role and label rendered by Chat.
+ */
+export function contextProvenance(source: unknown): ContextProvenanceView {
+  const record = asRecord(source)
+  const kind = record === null ? null : readString(record, 'kind')
+  if (record === null || kind === null) return { role: 'inject', label: null }
+  switch (kind) {
+    case 'session-reference':
+      return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
+    case 'agent-instructions':
+      return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
+    case 'plugin':
+      return { role: 'inject', label: readString(record, 'plugin') ?? kind }
+    case 'skill-invocation':
+      return { role: 'inject', label: readString(record, 'name') ?? kind }
+    default:
+      return { role: 'inject', label: kind }
+  }
+}
+
+/**
+ * Read distinct labels cited by a durable cross-session recall source.
+ * @param source - Logged `user/message` source.
+ * @returns Labels in first-seen order.
+ */
+export function sessionRecallLabels(source: unknown): string[] {
+  const record = asRecord(source)
+  if (record === null || readString(record, 'kind') !== 'session-reference') return []
+  return collect(record, 'references', 'label')
+}
+
+/**
+ * Classify finalized Assistant content for Chat rendering.
+ * @param content - Core content blocks.
+ * @returns Chat blocks in source order.
+ */
+export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
+  return content.map(toAssistantBlock)
+}
+
+/**
+ * Classify one finalized Assistant block for Chat rendering.
+ * @param block - Core content block.
+ * @returns Chat block.
+ */
+export function toAssistantBlock(block: ContentBlock): AssistantBlock {
+  switch (block.type) {
+    case 'text': return { kind: 'text', text: block.text }
+    case 'reasoning': return { kind: 'reasoning', text: block.text }
+    case 'image': return { kind: 'image', attachment: block.attachment }
+    case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
+    default: return { kind: 'other', block }
+  }
+}
+
+/**
+ * Create the initial Chat block for one streamed Assistant block kind.
+ * @param blockType - Wire block kind.
+ * @returns Empty block ready to receive deltas.
+ */
+export function emptyAssistantBlock(blockType: string): AssistantBlock {
+  switch (blockType) {
+    case 'text': return { kind: 'text', text: '' }
+    case 'reasoning': return { kind: 'reasoning', text: '' }
+    case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
+    default: return { kind: 'other', block: null }
+  }
+}
+
+/** Display-safe failure fields retained by Chat projections. */
+export interface DisplayFailure {
+  readonly code?: string
+  readonly message: string
+}
+
+/**
+ * Convert a durable failure to locale-independent fields safe for Chat.
+ * @param failure - Failure preserved by a Session event.
+ * @returns Sanitized message and optional stable provider code.
+ */
+export function displayFailure(failure: unknown): DisplayFailure {
+  if (failure === null || typeof failure !== 'object') return { message: String(failure) }
+  const record = failure as { code?: unknown; message?: unknown }
+  const code = typeof record.code === 'string' ? record.code : undefined
+  if (code === 'AUTH') return { code, message: '' }
+  return {
+    ...(code === undefined ? {} : { code }),
+    message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
+  }
+}
+
+/**
+ * Whether a stream chunk carries visible model output for Chat timing.
+ * @param chunk - Stream chunk to inspect.
+ * @returns true for a non-empty text, reasoning, or Tool-call delta.
+ */
+export function isTokenDelta(chunk: StreamChunk): boolean {
+  switch (chunk.type) {
+    case 'text-delta':
+    case 'reasoning-delta':
+      return chunk.text !== ''
+    case 'tool-call-delta':
+      return chunk.argumentsDelta !== '' || chunk.name !== undefined
+    default:
+      return false
+  }
+}
+
+/* jscpd:ignore-end */

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/message.ts

@@ -2,9 +2,9 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts'
-import { contextForm, contextProvenance } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { InboxState } from './inbox.ts'
 import { chatNode } from './common.ts'
+import { contextForm, contextProvenance } from './event-projection.ts'
 
 interface ReferencedUserMessageNode extends UserMessageNode {
   /** Labels cited by the immediately following session-reference context. */

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/partial.ts

@@ -1,6 +1,6 @@
 import type { StreamChunk } from '@deepseek-ai/dsh-llm/types'
 import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts'
-import { emptyAssistantBlock, toAssistantBlock } from '../contract/snapshot.ts'
+import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts'
 
 /**
  * Whether a stream chunk changes the partial assistant projection shown by the UI.

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts

@@ -3,8 +3,8 @@ import type {
   ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { TurnErrorNode } from '../contract/snapshot.ts'
-import { displayFailure } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { chatNode } from './common.ts'
+import { displayFailure } from './event-projection.ts'
 
 declare module '../contract/chat-nodes.ts' {
   interface ChatNodeDataMap {

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts

@@ -7,9 +7,9 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type {
   AssistantChatData, FinalAssistantChatData, TurnTailChatData,
 } from '../contract/chat-nodes.ts'
-import { toAssistantBlocks } from '../contract/snapshot.ts'
 import { deriveTurnMetrics } from '../contract/turn-metrics.ts'
 import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
+import { toAssistantBlocks } from './event-projection.ts'
 
 declare module '../contract/chat-nodes.ts' {
   interface ChatNodeDataMap {

+ 0 - 14
packages/client/ui-chat/src/client/details/tool-node-reader.ts

@@ -1,4 +1,3 @@
-import { conversationContextKey } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { ChatNode } from '../contract/chat-nodes.ts'
 import type { ChatNodeStore, ChatSnapshot, ToolCallBlock } from '../contract/snapshot.ts'
 
@@ -6,19 +5,6 @@ function toolNode(node: ReturnType<ChatNodeStore['get']>): ChatNode<'tool-call'>
   return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
 }
 
-/**
- * Read one root Tool lifecycle through the internal Chat Node index.
- * @param snapshot - current Conversation snapshot.
- * @param rootCallId - root call identity and Tool Context identity.
- * @returns root lifecycle when it is materialized in the current window.
- */
-export function rootToolCall(
-  snapshot: ChatSnapshot,
-  rootCallId: string,
-): ToolCallBlock | undefined {
-  return toolNode(snapshot.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root
-}
-
 /**
  * Find any root or nested Tool lifecycle through the internal Node store.
  * @param snapshot - current Conversation snapshot.

+ 1 - 4
packages/client/ui-chat/src/client/index.ts

@@ -41,10 +41,7 @@ export type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
 export { isRunningTool, isSettledTool } from './contract/chat-nodes.ts'
-export { EMPTY_CHAT_SNAPSHOT, toAssistantBlock, toAssistantBlocks } from './contract/snapshot.ts'
-export {
-  contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
-} from '@deepseek-ai/dsh-client-ui-conversation/client'
+export { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
 
 /** Public merge surface for Chat renderer payloads contributed by other plugins. */
 export interface ChatNodeDataMap {}

+ 1 - 19
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -9,7 +9,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
+import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
 import { en, zh } from '../src/client/locale.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 
@@ -262,24 +262,6 @@ describe('StatsLine', () => {
       .toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
   })
 
-  it('computes context occupancy only when both a numerator and capacity are known', () => {
-    // The projected figure wins: it is the provider sample carried forward over
-    // the surface's movement, so a compaction shows without waiting a request.
-    expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
-      .toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
-    // A log whose projection predates the field still reads its bare sample.
-    expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
-      .toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
-    // A numerator without capacity has no denominator; capacity without a
-    // provider sample has no numerator yet, rather than a synthetic 0%.
-    expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
-    expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
-    expect(contextOccupancy(undefined)).toBeNull()
-    // Capacity and the sample are independent last-wins fields, so a model
-    // switch can pair a smaller new window with the previous route's prompt.
-    expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
-  })
-
   it('drops every token group when no projection is composed', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsLine {...props(source, {})} />)

+ 30 - 2
packages/client/ui-chat/tests/conversation.client.spec.ts

@@ -1,9 +1,12 @@
-/** Assistant block classifier (moved here with sessions/conversation.ts). */
+/** Chat-owned event-to-view projection. */
 
 import { describe, expect, it } from 'vitest'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ContentBlock } from '@deepseek-ai/dsh-api-remotes/client'
-import { toAssistantBlock, toAssistantBlocks } from '../src/client/contract/snapshot.ts'
+import {
+  displayFailure, emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
+  isTokenDelta,
+} from '../src/client/conversation-nodes/event-projection.ts'
 
 describe('toAssistantBlock', () => {
   it('classifies the four block shapes', () => {
@@ -27,5 +30,30 @@ describe('toAssistantBlock', () => {
       { kind: 'image', attachment },
     ])
     expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' })
+    expect(toAssistantBlock({ type: 'future' } as unknown as ContentBlock))
+      .toEqual({ kind: 'other', block: { type: 'future' } })
+  })
+
+  it('creates empty streamed block projections', () => {
+    expect(emptyAssistantBlock('text')).toEqual({ kind: 'text', text: '' })
+    expect(emptyAssistantBlock('reasoning')).toEqual({ kind: 'reasoning', text: '' })
+    expect(emptyAssistantBlock('tool-call')).toEqual({ kind: 'tool-call', callId: '', name: '', argsRaw: '' })
+    expect(emptyAssistantBlock('future')).toEqual({ kind: 'other', block: null })
+  })
+
+  it('redacts auth failures and presents the remaining durable values', () => {
+    expect(displayFailure({ code: 'AUTH', message: 'secret' })).toEqual({ code: 'AUTH', message: '' })
+    expect(displayFailure({ code: 'TRANSPORT', message: 'offline' }))
+      .toEqual({ code: 'TRANSPORT', message: 'offline' })
+    expect(displayFailure({ code: 'UNKNOWN' })).toEqual({ code: 'UNKNOWN', message: '{"code":"UNKNOWN"}' })
+    expect(displayFailure(null)).toEqual({ message: 'null' })
+  })
+
+  it('recognizes only non-empty token deltas', () => {
+    expect(isTokenDelta({ type: 'text-delta', index: 0, text: 'x' } as never)).toBe(true)
+    expect(isTokenDelta({ type: 'reasoning-delta', index: 0, text: '' } as never)).toBe(false)
+    expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '', name: 'tool' } as never)).toBe(true)
+    expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' } as never)).toBe(false)
+    expect(isTokenDelta({ type: 'finish', reason: 'stop' } as never)).toBe(false)
   })
 })

BIN
packages/client/ui-conversation/tests/context-provenance.client.spec.ts → packages/client/ui-chat/tests/event-projection.client.spec.ts


+ 2 - 107
packages/client/ui-conversation/src/client/contract/context-provenance.ts

@@ -1,8 +1,4 @@
-// Conversation context source projection: the role and the human-facing producer name
-// of one logged non-user `user/message`, read from its durable `source` alone.
-// The client keeps no table of known plugin ids — a renamed or newly mounted
-// producer must never need a client release to stay identifiable, and a resumed
-// or foreign log must project the same way as a live one.
+/** Shared types for target-owned context-source projections. */
 
 /**
  * Which model-facing role a logged non-user message plays.
@@ -27,106 +23,5 @@ export interface ContextProvenanceView {
   label: string | null
 }
 
-/** One durable source narrowed to the readable-record shape; null for anything else. */
-function asRecord(value: unknown): Record<string, unknown> | null {
-  return typeof value === 'object' && value !== null && !Array.isArray(value)
-    ? value as Record<string, unknown>
-    : null
-}
-
-/** A record field read as a non-empty string, or null. */
-function readString(record: Record<string, unknown>, key: string): string | null {
-  const value = record[key]
-  return typeof value === 'string' && value.length > 0 ? value : null
-}
-
-/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
-function collect(source: Record<string, unknown>, member: string, field: string): string[] {
-  const list = source[member]
-  if (!Array.isArray(list)) return []
-  const seen: string[] = []
-  for (const entry of list) {
-    const record = asRecord(entry)
-    const value = record === null ? null : readString(record, field)
-    if (value !== null && !seen.includes(value)) seen.push(value)
-  }
-  return seen
-}
-
-/** A collected name list rendered as one label; null when the list is empty. */
-function joined(names: string[]): string | null {
-  return names.length > 0 ? names.join(', ') : null
-}
-
-/**
- * The referenced-session labels of one durable `session-reference` recall
- * source, in first-seen order; empty for every other source shape, including
- * a foreign or older log whose reference entries carry no readable label.
- * @param source - the logged `user/message` source, exactly as recorded.
- * @returns distinct non-empty reference labels.
- */
-export function sessionRecallLabels(source: unknown): string[] {
-  const record = asRecord(source)
-  if (record === null || readString(record, 'kind') !== 'session-reference') return []
-  return collect(record, 'references', 'label')
-}
-
-/**
- * Project one durable message source onto its transcript role and producer name.
- *
- * The source arrives over the wire as opaque JSON (`MessageSource` is
- * merge-extensible, so no client-side union can be exhaustive), and a durable
- * log may predate or postdate this UI; every unreadable shape therefore
- * degrades to `inject` with whatever name the record still carries.
- * @param source - the logged `user/message` source, exactly as recorded.
- * @returns the role and producer name to present for this context.
- */
-export function contextProvenance(source: unknown): ContextProvenanceView {
-  const record = asRecord(source)
-  const kind = record === null ? null : readString(record, 'kind')
-  if (record === null || kind === null) return { role: 'inject', label: null }
-  switch (kind) {
-    // Cross-session snapshots are the one durable source that carries another
-    // session's material; its references name the sessions they were read from.
-    case 'session-reference':
-      return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
-    // Workspace instructions name the files they were reconciled from, which
-    // identifies the producer far better than the plugin id would.
-    case 'agent-instructions':
-      return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
-    case 'plugin':
-      return { role: 'inject', label: readString(record, 'plugin') ?? kind }
-    // A user-explicit skill invocation names the skill it injected.
-    case 'skill-invocation':
-      return { role: 'inject', label: readString(record, 'name') ?? kind }
-    // Documented default arm of the merge-extensible source map: an unknown
-    // producer still identifies itself by its own durable kind.
-    default:
-      return { role: 'inject', label: kind }
-  }
-}
-
-/**
- * Context forms this UI version renders with a dedicated presentation. The
- * durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
- * unrecognized or absent value degrades to the opaque presentation rather than
- * dropping the row, so a log written by a newer or foreign producer still
- * renders.
- */
-const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
-
 /** One durable context form this UI version knows how to present. */
-export type KnownContextForm = typeof KNOWN_FORMS[number]
-
-/**
- * Read the producer-declared form off one durable message source.
- * @param source - the logged `user/message` source, exactly as recorded.
- * @returns the form when this UI version presents it, otherwise null (opaque).
- */
-export function contextForm(source: unknown): KnownContextForm | null {
-  const record = asRecord(source)
-  const form = record === null ? null : readString(record, 'form')
-  return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
-    ? form as KnownContextForm
-    : null
-}
+export type KnownContextForm = 'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall'

+ 3 - 42
packages/client/ui-conversation/src/client/contract/records.ts

@@ -32,8 +32,7 @@ export interface AssistantProvenanceView {
   model: string
 }
 
-/** Assistant content blocks sorted by what the UI cares about
- *  (text body / collapsible reasoning / tool-call card head / other fallback). */
+/** Assistant content blocks sorted by what a UI target presents. */
 export type AssistantBlock =
   | { kind: 'text'; text: string }
   | { kind: 'reasoning'; text: string }
@@ -41,44 +40,6 @@ export type AssistantBlock =
   | { kind: 'tool-call'; callId: string; name: string; argsRaw: string }
   | { kind: 'other'; block: unknown }
 
-/**
- * core ContentBlock[] -> AssistantBlock[] (classifier shared by finalized messages and partial block-end).
- * @param content - core content blocks verbatim.
- * @returns UI-classified blocks in source order.
- */
-export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
-  return content.map(toAssistantBlock)
-}
-
-/**
- * Classify one block (ToolCallBlock fields are id/arguments, mapped to callId/argsRaw).
- * @param block - one core content block.
- * @returns the UI classification.
- */
-export function toAssistantBlock(block: ContentBlock): AssistantBlock {
-  switch (block.type) {
-    case 'text': return { kind: 'text', text: block.text }
-    case 'reasoning': return { kind: 'reasoning', text: block.text }
-    case 'image': return { kind: 'image', attachment: block.attachment }
-    case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
-    default: return { kind: 'other', block }
-  }
-}
-
-/**
- * Create the empty projection for one streamed Assistant block kind.
- * @param blockType - wire block kind.
- * @returns empty projected block ready to receive deltas.
- */
-export function emptyAssistantBlock(blockType: string): AssistantBlock {
-  switch (blockType) {
-    case 'text': return { kind: 'text', text: '' }
-    case 'reasoning': return { kind: 'reasoning', text: '' }
-    case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
-    default: return { kind: 'other', block: null }
-  }
-}
-
 /** A finalized user message. */
 export interface UserMessageNode {
   kind: 'user'
@@ -145,9 +106,9 @@ export interface ContextMessageNode {
   time: number
   content: readonly ContentBlock[]
   source: unknown
-  /** Role and producer name projected from `source` ({@link contextProvenance}). */
+  /** Role and producer name projected from `source` by the target. */
   provenance: ContextProvenanceView
-  /** Producer-declared information form ({@link contextForm}); null presents as opaque. */
+  /** Producer-declared information form supported by the target; null presents as opaque. */
   form: KnownContextForm | null
 }
 

+ 0 - 70
packages/client/ui-conversation/src/client/conversation/assistant-timing.ts

@@ -1,70 +0,0 @@
-// Shared assistant step-timing fold: target Definitions and Trajectory
-// history fold derive AssistantTiming from the same step/start -> first token
-// delta -> assistant/message sequence.
-
-import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
-import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { AssistantTiming } from '../contract/records.ts'
-
-// The first-token predicate lives beside the StreamChunk type in dsh-llm;
-// re-exported here for consumers sharing the Conversation timing fold.
-export { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
-
-/** Pre-finalize timing boundaries for one assistant step (start + first token). */
-export interface AssistantStepMetadata {
-  stepStartTime: number | null
-  firstTokenTime: number | null
-}
-
-/**
- * Composite map key for one assistant step.
- * @param turn - turn number from the event payload.
- * @param step - step number from the event payload.
- * @returns collision-free `turn`/`step` key (NUL separator).
- */
-export function assistantStepKey(turn: number, step: number): string {
-  return `${turn}\u0000${step}`
-}
-
-/**
- * Fold one event into the per-step timing index: step/start opens the entry,
- * the first non-empty token delta stamps first-token time once. Other event
- * types are no-ops.
- * @param steps - the mutable per-step index, keyed by {@link assistantStepKey}.
- * @param event - the raw window event.
- */
-export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void {
-  if (event.type === 'step/start') {
-    steps.set(
-      assistantStepKey(event.data.turn, event.data.step),
-      { stepStartTime: event.time, firstTokenTime: null },
-    )
-  } else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
-    const key = assistantStepKey(event.data.turn, event.data.step)
-    const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null }
-    if (current.firstTokenTime === null) {
-      steps.set(key, { ...current, firstTokenTime: event.time })
-    }
-  }
-}
-
-/**
- * Settle one finalized assistant message's timing from its step entry; a step
- * whose start or first token fell outside the window yields null boundaries.
- * @param steps - the per-step index built by {@link indexAssistantStepTiming}.
- * @param turn - the assistant/message turn number.
- * @param step - the assistant/message step number.
- * @param completedTime - the assistant/message event timestamp (epoch ms).
- * @returns the node-ready timing record.
- */
-export function settledAssistantTiming(
-  steps: ReadonlyMap<string, AssistantStepMetadata>,
-  turn: number,
-  step: number,
-  completedTime: number,
-): AssistantTiming {
-  return {
-    ...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }),
-    completedTime,
-  }
-}

+ 0 - 25
packages/client/ui-conversation/src/client/conversation/failure-display.ts

@@ -1,25 +0,0 @@
-/** Display-safe failure fields retained by locale-independent projections. */
-export interface DisplayFailure {
-  /** Stable provider failure code used for localized known-error copy. */
-  code?: string
-  /** Sanitized provider message; empty when the code owns the display copy. */
-  message: string
-}
-
-/**
- * Convert a durable failure into locale-independent fields safe for GUI projections.
- * @param failure - Failure value preserved by the session event.
- * @returns Sanitized message and optional stable provider code.
- */
-export function displayFailure(failure: unknown): DisplayFailure {
-  if (failure === null || typeof failure !== 'object') return { message: String(failure) }
-  const record = failure as { code?: unknown; message?: unknown }
-  const code = typeof record.code === 'string' ? record.code : undefined
-  // Provider AUTH messages may echo a masked or partially preserved credential.
-  // Keep the raw diagnostic in the session log, but never project it into UI state.
-  if (code === 'AUTH') return { code, message: '' }
-  return {
-    ...(code === undefined ? {} : { code }),
-    message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
-  }
-}

+ 0 - 20
packages/client/ui-conversation/src/client/index.ts

@@ -4,14 +4,6 @@ export { UiConversation } from './conversation/assembly.ts'
 export type { ConversationBinding } from './conversation/assembly.ts'
 export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
 export type { IConversation } from './service.ts'
-export { bytesToBase64 } from './browser-bytes.ts'
-export { settlePendingComposer } from './pending-composer.ts'
-export { contextOccupancy } from './context-occupancy.ts'
-export type { ContextOccupancy } from './context-occupancy.ts'
-export { ReferenceIcon } from './skeleton/ReferenceIcon.tsx'
-export type { ReferenceIconKind, ReferenceIconProps } from './skeleton/ReferenceIcon.tsx'
-
-export { conversationContextKey } from './contract/conversation.ts'
 export type {
   ConversationContextReader, ConversationEventInput, ConversationLocation,
   ConversationLocationData, ConversationLocationDataScope, ConversationLocationDataStore,
@@ -32,24 +24,12 @@ export type {
   ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
   UserMessageNode,
 } from './contract/records.ts'
-export {
-  emptyAssistantBlock, toAssistantBlock, toAssistantBlocks,
-} from './contract/records.ts'
 export type {
   ContextProvenanceView, ContextRole, KnownContextForm,
 } from './contract/context-provenance.ts'
-export {
-  contextForm, contextProvenance, sessionRecallLabels,
-} from './contract/context-provenance.ts'
 export type {
   ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
 } from './contract/request-inspection.ts'
-export type { AssistantStepMetadata } from './conversation/assistant-timing.ts'
-export {
-  assistantStepKey, indexAssistantStepTiming, isTokenDelta, settledAssistantTiming,
-} from './conversation/assistant-timing.ts'
-export { displayFailure } from './conversation/failure-display.ts'
-export type { DisplayFailure } from './conversation/failure-display.ts'
 export type { ConversationStoreState, ConversationViewRequest, ViewTab } from './contract/views.ts'
 
 export { ConversationNodeAssembler } from './conversation/assembler.ts'

+ 12 - 0
packages/client/ui-conversation/tests/context-meter.client.spec.tsx

@@ -5,6 +5,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
 import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
+import { contextOccupancy } from '../src/client/context-occupancy.ts'
 import css from '../src/client/skeleton/ContextMeter.module.css'
 import { en, zh } from '../src/client/locales.ts'
 
@@ -27,6 +28,17 @@ function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'
 }
 
 describe('ContextMeter', () => {
+  it('computes occupancy only when both a numerator and capacity are known', () => {
+    expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
+      .toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
+    expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
+      .toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
+    expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
+    expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
+    expect(contextOccupancy(undefined)).toBeNull()
+    expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
+  })
+
   it('renders nothing until both pressure and capacity are known', () => {
     expect(meter({}).container.textContent).toBe('')
     expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')

+ 0 - 23
packages/client/ui-conversation/tests/failure-display.client.spec.ts

@@ -1,23 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { displayFailure } from '../src/client/conversation/failure-display.ts'
-
-describe('displayFailure', () => {
-  it('keeps ordinary diagnostics and stable provider codes', () => {
-    expect(displayFailure(null)).toEqual({ message: 'null' })
-    expect(displayFailure('disconnected')).toEqual({ message: 'disconnected' })
-    expect(displayFailure({ code: 'RATE_LIMIT', message: 'try later' })).toEqual({
-      code: 'RATE_LIMIT',
-      message: 'try later',
-    })
-    expect(displayFailure({ detail: 'unknown' })).toEqual({
-      message: '{"detail":"unknown"}',
-    })
-  })
-
-  it('removes the provider message when AUTH owns localized display copy', () => {
-    expect(displayFailure({ code: 'AUTH', message: 'credential sk-secret failed' })).toEqual({
-      code: 'AUTH',
-      message: '',
-    })
-  })
-})

+ 7 - 6
packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts

@@ -1,12 +1,13 @@
 import type { Context } from '@deepseek-ai/cordis'
-import {
-  displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock,
-  toAssistantBlocks,
-  type AssistantBlock, type AssistantMessageNode, type ConversationLocation,
-  type ConversationMatch, type ConversationNodeContext, type ConversationNodeDefinition,
-  type PartialAssistant, type RequestView,
+import type {
+  AssistantBlock, AssistantMessageNode, ConversationLocation,
+  ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
+  PartialAssistant, RequestView,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { trajectoryNode } from './trajectory-definition-common.ts'
+import {
+  displayFailure, emptyAssistantBlock, isTokenDelta, toAssistantBlock, toAssistantBlocks,
+} from './trajectory-event-projection.ts'
 
 /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
  * state machines independent; see ../../../../../.agents/notes/implemented/

+ 154 - 0
packages/client/ui-trajectory/src/client/trajectory-event-projection.ts

@@ -0,0 +1,154 @@
+/** Trajectory-owned conversion from durable Session events to ledger view data. */
+
+import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types'
+import type {
+  AssistantBlock, ContextProvenanceView, KnownContextForm,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
+
+/* jscpd:ignore-start -- Chat and Trajectory own independent event-to-view projections. */
+
+function asRecord(value: unknown): Record<string, unknown> | null {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : null
+}
+
+function readString(record: Record<string, unknown>, key: string): string | null {
+  const value = record[key]
+  return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+function collect(source: Record<string, unknown>, member: string, field: string): string[] {
+  const list = source[member]
+  if (!Array.isArray(list)) return []
+  const seen: string[] = []
+  for (const entry of list) {
+    const record = asRecord(entry)
+    const value = record === null ? null : readString(record, field)
+    if (value !== null && !seen.includes(value)) seen.push(value)
+  }
+  return seen
+}
+
+function joined(names: string[]): string | null {
+  return names.length > 0 ? names.join(', ') : null
+}
+
+const KNOWN_FORMS: readonly KnownContextForm[] = [
+  'instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall',
+]
+
+/**
+ * Read the target-supported presentation form from a durable message source.
+ * @param source - Logged `user/message` source.
+ * @returns Supported form, or null for the opaque presentation.
+ */
+export function contextForm(source: unknown): KnownContextForm | null {
+  const record = asRecord(source)
+  const form = record === null ? null : readString(record, 'form')
+  return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
+    ? form as KnownContextForm
+    : null
+}
+
+/**
+ * Project a durable message source to the Trajectory row's role and producer label.
+ * @param source - Logged `user/message` source.
+ * @returns Role and label rendered by Trajectory.
+ */
+export function contextProvenance(source: unknown): ContextProvenanceView {
+  const record = asRecord(source)
+  const kind = record === null ? null : readString(record, 'kind')
+  if (record === null || kind === null) return { role: 'inject', label: null }
+  switch (kind) {
+    case 'session-reference':
+      return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
+    case 'agent-instructions':
+      return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
+    case 'plugin':
+      return { role: 'inject', label: readString(record, 'plugin') ?? kind }
+    case 'skill-invocation':
+      return { role: 'inject', label: readString(record, 'name') ?? kind }
+    default:
+      return { role: 'inject', label: kind }
+  }
+}
+
+/**
+ * Classify finalized Assistant content for Trajectory rendering.
+ * @param content - Core content blocks.
+ * @returns Trajectory blocks in source order.
+ */
+export function toAssistantBlocks(content: readonly ContentBlock[]): AssistantBlock[] {
+  return content.map(toAssistantBlock)
+}
+
+/**
+ * Classify one finalized Assistant block for Trajectory rendering.
+ * @param block - Core content block.
+ * @returns Trajectory block.
+ */
+export function toAssistantBlock(block: ContentBlock): AssistantBlock {
+  switch (block.type) {
+    case 'text': return { kind: 'text', text: block.text }
+    case 'reasoning': return { kind: 'reasoning', text: block.text }
+    case 'image': return { kind: 'image', attachment: block.attachment }
+    case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments }
+    default: return { kind: 'other', block }
+  }
+}
+
+/**
+ * Create the initial Trajectory block for one streamed Assistant block kind.
+ * @param blockType - Wire block kind.
+ * @returns Empty block ready to receive deltas.
+ */
+export function emptyAssistantBlock(blockType: string): AssistantBlock {
+  switch (blockType) {
+    case 'text': return { kind: 'text', text: '' }
+    case 'reasoning': return { kind: 'reasoning', text: '' }
+    case 'tool-call': return { kind: 'tool-call', callId: '', name: '', argsRaw: '' }
+    default: return { kind: 'other', block: null }
+  }
+}
+
+/** Display-safe failure fields retained by Trajectory projections. */
+export interface DisplayFailure {
+  readonly code?: string
+  readonly message: string
+}
+
+/**
+ * Convert a durable failure to locale-independent fields safe for Trajectory.
+ * @param failure - Failure preserved by a Session event.
+ * @returns Sanitized message and optional stable provider code.
+ */
+export function displayFailure(failure: unknown): DisplayFailure {
+  if (failure === null || typeof failure !== 'object') return { message: String(failure) }
+  const record = failure as { code?: unknown; message?: unknown }
+  const code = typeof record.code === 'string' ? record.code : undefined
+  if (code === 'AUTH') return { code, message: '' }
+  return {
+    ...(code === undefined ? {} : { code }),
+    message: typeof record.message === 'string' ? record.message : JSON.stringify(failure),
+  }
+}
+
+/**
+ * Whether a stream chunk carries visible model output for Trajectory timing.
+ * @param chunk - Stream chunk to inspect.
+ * @returns true for a non-empty text, reasoning, or Tool-call delta.
+ */
+export function isTokenDelta(chunk: StreamChunk): boolean {
+  switch (chunk.type) {
+    case 'text-delta':
+    case 'reasoning-delta':
+      return chunk.text !== ''
+    case 'tool-call-delta':
+      return chunk.argumentsDelta !== '' || chunk.name !== undefined
+    default:
+      return false
+  }
+}
+
+/* jscpd:ignore-end */

+ 4 - 4
packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts

@@ -1,11 +1,11 @@
 import type { Context } from '@deepseek-ai/cordis'
-import {
-  contextForm, contextProvenance,
-  type ContextMessageNode, type ConversationNodeDefinition, type ConversationPreviousContext,
-  type SteeringMessageNode, type UserMessageNode,
+import type {
+  ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext,
+  SteeringMessageNode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-agent/types'
 import { trajectoryNode } from './trajectory-definition-common.ts'
+import { contextForm, contextProvenance } from './trajectory-event-projection.ts'
 
 /* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event
  * state machines independent; see ../../../../../.agents/notes/implemented/

+ 70 - 0
packages/client/ui-trajectory/tests/event-projection.client.spec.ts

@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import {
+  contextForm, contextProvenance, displayFailure, emptyAssistantBlock, isTokenDelta,
+  toAssistantBlock, toAssistantBlocks,
+} from '../src/client/trajectory-event-projection.ts'
+
+describe('Trajectory event projection', () => {
+  it('projects known, unknown, and unreadable context sources', () => {
+    expect(contextProvenance({ kind: 'session-reference', references: [{ label: 'A' }, { label: 'A' }] }))
+      .toEqual({ role: 'recall', label: 'A' })
+    expect(contextProvenance({ kind: 'session-reference', references: [] }))
+      .toEqual({ role: 'recall', label: 'session-reference' })
+    expect(contextProvenance({ kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }, null] }))
+      .toEqual({ role: 'inject', label: 'AGENTS.md' })
+    expect(contextProvenance({ kind: 'agent-instructions', changes: 'bad' }).label)
+      .toBe('agent-instructions')
+    expect(contextProvenance({ kind: 'plugin', plugin: 'p' }).label).toBe('p')
+    expect(contextProvenance({ kind: 'plugin', plugin: 1 }).label).toBe('plugin')
+    expect(contextProvenance({ kind: 'skill-invocation', name: 's' }).label).toBe('s')
+    expect(contextProvenance({ kind: 'future' }).label).toBe('future')
+    expect(contextProvenance(null)).toEqual({ role: 'inject', label: null })
+    expect(contextProvenance([])).toEqual({ role: 'inject', label: null })
+    expect(contextProvenance({ kind: '' })).toEqual({ role: 'inject', label: null })
+  })
+
+  it('accepts only forms supported by the target', () => {
+    for (const form of ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall']) {
+      expect(contextForm({ form })).toBe(form)
+    }
+    expect(contextForm({ form: 'future' })).toBeNull()
+    expect(contextForm({ form: 1 })).toBeNull()
+    expect(contextForm(null)).toBeNull()
+  })
+
+  it('projects finalized and empty Assistant blocks', () => {
+    const content = [
+      { type: 'text', text: 'a' },
+      { type: 'reasoning', text: 'b' },
+      { type: 'image', attachment: { attachmentId: 'x' } },
+      { type: 'tool-call', id: 'c', name: 'n', arguments: '{}' },
+      { type: 'future' },
+    ] as unknown as ContentBlock[]
+    expect(toAssistantBlocks(content).map(block => block.kind))
+      .toEqual(['text', 'reasoning', 'image', 'tool-call', 'other'])
+    expect(toAssistantBlock(content[0]!)).toEqual({ kind: 'text', text: 'a' })
+    expect(['text', 'reasoning', 'tool-call', 'future'].map(emptyAssistantBlock))
+      .toEqual([
+        { kind: 'text', text: '' },
+        { kind: 'reasoning', text: '' },
+        { kind: 'tool-call', callId: '', name: '', argsRaw: '' },
+        { kind: 'other', block: null },
+      ])
+  })
+
+  it('redacts auth failures and presents other durable values', () => {
+    expect(displayFailure({ code: 'AUTH', message: 'secret' })).toEqual({ code: 'AUTH', message: '' })
+    expect(displayFailure({ message: 'offline' })).toEqual({ message: 'offline' })
+    expect(displayFailure({ code: 'UNKNOWN' })).toEqual({ code: 'UNKNOWN', message: '{"code":"UNKNOWN"}' })
+    expect(displayFailure(undefined)).toEqual({ message: 'undefined' })
+  })
+
+  it('recognizes only non-empty token deltas', () => {
+    expect(isTokenDelta({ type: 'text-delta', index: 0, text: 'x' } as never)).toBe(true)
+    expect(isTokenDelta({ type: 'reasoning-delta', index: 0, text: '' } as never)).toBe(false)
+    expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '}' } as never)).toBe(true)
+    expect(isTokenDelta({ type: 'tool-call-delta', index: 0, id: 'c', argumentsDelta: '' } as never)).toBe(false)
+    expect(isTokenDelta({ type: 'finish', reason: 'stop' } as never)).toBe(false)
+  })
+})