mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(client): localize conversation projections
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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 */
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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, {})} />)
|
||||
|
||||
@@ -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
Binary file not shown.
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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/
|
||||
|
||||
@@ -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 */
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user