From 9f2f498e7cb834a92fcd0607cfef56bbc5ba1cd6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:49:42 +0800 Subject: [PATCH] refactor(client): localize conversation projections --- .../client/connection/src/client/fixture.ts | 18 +- .../ui-chat/src/client/chat/StatsLine.tsx | 3 - .../ui-chat/src/client/contract/chat-nodes.ts | 8 +- .../ui-chat/src/client/contract/snapshot.ts | 4 - .../client/conversation-nodes/assistant.ts | 6 +- .../chat-snapshot-builder.ts | 2 +- .../conversation-nodes/event-projection.ts | 165 ++++++++++++++++++ .../src/client/conversation-nodes/message.ts | 2 +- .../src/client/conversation-nodes/partial.ts | 2 +- .../client/conversation-nodes/turn-error.ts | 2 +- .../client/conversation-nodes/turn-tail.ts | 2 +- .../src/client/details/tool-node-reader.ts | 14 -- packages/client/ui-chat/src/client/index.ts | 5 +- .../ui-chat/tests/chat-stats.client.spec.tsx | 20 +-- .../ui-chat/tests/conversation.client.spec.ts | 32 +++- .../tests/event-projection.client.spec.ts} | Bin 5164 -> 5356 bytes .../src/client/contract/context-provenance.ts | 109 +----------- .../src/client/contract/records.ts | 45 +---- .../client/conversation/assistant-timing.ts | 70 -------- .../client/conversation/failure-display.ts | 25 --- .../ui-conversation/src/client/index.ts | 20 --- .../tests/context-meter.client.spec.tsx | 12 ++ .../tests/failure-display.client.spec.ts | 23 --- .../client/trajectory-assistant-definition.ts | 13 +- .../src/client/trajectory-event-projection.ts | 154 ++++++++++++++++ .../client/trajectory-message-definitions.ts | 8 +- .../tests/event-projection.client.spec.ts | 70 ++++++++ 27 files changed, 476 insertions(+), 358 deletions(-) create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts rename packages/client/{ui-conversation/tests/context-provenance.client.spec.ts => ui-chat/tests/event-projection.client.spec.ts} (95%) delete mode 100644 packages/client/ui-conversation/src/client/conversation/assistant-timing.ts delete mode 100644 packages/client/ui-conversation/src/client/conversation/failure-display.ts delete mode 100644 packages/client/ui-conversation/tests/failure-display.client.spec.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-event-projection.ts create mode 100644 packages/client/ui-trajectory/tests/event-projection.client.spec.ts diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5dedcee80b..8d6c16cfce 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/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 diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.tsx b/packages/client/ui-chat/src/client/chat/StatsLine.tsx index be78b172c2..a2f9f6be60 100644 --- a/packages/client/ui-chat/src/client/chat/StatsLine.tsx +++ b/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 diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index b4f8605c4f..6f334d5e13 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/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 { diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 9a938cfa09..35b1562c5b 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/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. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts b/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts index 7eb11d7ab9..f0f2b9be88 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/assistant.ts +++ b/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 { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index ce55ef45e9..f70e657014 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/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[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts new file mode 100644 index 0000000000..a03bec7fdc --- /dev/null +++ b/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 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function readString(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function collect(source: Record, 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 */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index d8a09ddd53..9611a36cf8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/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. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index cc94609bb3..ea52a3a565 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/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. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index f4b0695f0b..e3b41095ce 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/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 { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts index 23d3779e0f..9d2986484b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-tail.ts +++ b/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 { diff --git a/packages/client/ui-chat/src/client/details/tool-node-reader.ts b/packages/client/ui-chat/src/client/details/tool-node-reader.ts index 690cea34bc..9e2488b68f 100644 --- a/packages/client/ui-chat/src/client/details/tool-node-reader.ts +++ b/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): 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. diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 1816fae928..a847b039fd 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/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 {} diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 529e7d9f00..1600ccfdef 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/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() diff --git a/packages/client/ui-chat/tests/conversation.client.spec.ts b/packages/client/ui-chat/tests/conversation.client.spec.ts index 4664b92879..b7b411a40d 100644 --- a/packages/client/ui-chat/tests/conversation.client.spec.ts +++ b/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) }) }) diff --git a/packages/client/ui-conversation/tests/context-provenance.client.spec.ts b/packages/client/ui-chat/tests/event-projection.client.spec.ts similarity index 95% rename from packages/client/ui-conversation/tests/context-provenance.client.spec.ts rename to packages/client/ui-chat/tests/event-projection.client.spec.ts index 3259aac23e68b0aad326df29f318f92cee14ef47..aa333c4cba73aac3784d023c5f4b8ec9af2e8757 100644 GIT binary patch delta 129 zcmZ3Z@kVol4x>U@YEf}wNoIbYZeD&$YO#K5S!!O1Zb4CgR%$Xxc(V&58>4h$oa#QDSn5esX?ZNoqxjZb4CgS!!NlUUKSYA4WFD&0Nfn1OZ!E4@m$3 diff --git a/packages/client/ui-conversation/src/client/contract/context-provenance.ts b/packages/client/ui-conversation/src/client/contract/context-provenance.ts index 8fb154e558..ef5827eaab 100644 --- a/packages/client/ui-conversation/src/client/contract/context-provenance.ts +++ b/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 | null { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : null -} - -/** A record field read as a non-empty string, or null. */ -function readString(record: Record, 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, 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' diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index a92e284ca8..a03ac77ce7 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/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 } diff --git a/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts b/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts deleted file mode 100644 index cf6e0d91e8..0000000000 --- a/packages/client/ui-conversation/src/client/conversation/assistant-timing.ts +++ /dev/null @@ -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, 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, - turn: number, - step: number, - completedTime: number, -): AssistantTiming { - return { - ...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }), - completedTime, - } -} diff --git a/packages/client/ui-conversation/src/client/conversation/failure-display.ts b/packages/client/ui-conversation/src/client/conversation/failure-display.ts deleted file mode 100644 index 85fdf89896..0000000000 --- a/packages/client/ui-conversation/src/client/conversation/failure-display.ts +++ /dev/null @@ -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), - } -} diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 33474321f1..0d6874f5a3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/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' diff --git a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx index 83499cd6d3..75b9a59d0c 100644 --- a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx +++ b/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, 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('') diff --git a/packages/client/ui-conversation/tests/failure-display.client.spec.ts b/packages/client/ui-conversation/tests/failure-display.client.spec.ts deleted file mode 100644 index 0db2ae6834..0000000000 --- a/packages/client/ui-conversation/tests/failure-display.client.spec.ts +++ /dev/null @@ -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: '', - }) - }) -}) 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 6c0e0a3a30..98df63117e 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/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/ diff --git a/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts b/packages/client/ui-trajectory/src/client/trajectory-event-projection.ts new file mode 100644 index 0000000000..564d13fb71 --- /dev/null +++ b/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 | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null +} + +function readString(record: Record, key: string): string | null { + const value = record[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function collect(source: Record, 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 */ 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 2dfc871ad4..d8b714197a 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/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/ diff --git a/packages/client/ui-trajectory/tests/event-projection.client.spec.ts b/packages/client/ui-trajectory/tests/event-projection.client.spec.ts new file mode 100644 index 0000000000..9d2d8ff433 --- /dev/null +++ b/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) + }) +})