Files
deepseek-harness/packages/client/ui-tool/tests/tool-details-render.client.tsx
T

166 lines
5.3 KiB
TypeScript

/** Test adapter for the production conversation.details.tool registration. */
import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
DetailsToolOwnerProps, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
type TrajectorySnapshot = Parameters<Parameters<DetailsSlotProps['useTrajectory']>[0]>[0]
const emptyTrajectory: TrajectorySnapshot = {
eventNodes: [],
eventLocations: new Map(),
requests: [],
callSchemas: new Map(),
partial: null,
runningCalls: [],
}
/** Stable empty Trajectory source for DetailsPanel fixtures. */
export const useEmptyTrajectory: DetailsSlotProps['useTrajectory'] = selector => selector(emptyTrajectory)
function jsonFixture(value: unknown): JsonValue {
if (!isJsonValue(value)) throw new Error('tool event fixture must be lossless JSON')
return value as JsonValue
}
/** Build the canonical Chat slice consumed by Tool rows and details tests. */
export function toolChatSnapshot(
settled: readonly ConversationNode[] = [],
running: readonly RunningToolCall[] = [],
): ChatSnapshot {
const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running]
const nodes: ChatConversationViewNode[] = roots.map(root => ({
key: `tool:${root.callId}`,
kind: 'tool-call',
id: root.callId,
target: 'chat',
anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER,
location: { kind: 'session' },
visibility: 'visible',
data: { root },
}))
const byKey = new Map(nodes.map(node => [node.key, node]))
const empty: readonly string[] = []
return {
order: nodes.map(node => node.key),
nodes: {
get: key => byKey.get(key),
values: () => nodes,
},
locations: {
getTurn: () => empty,
getStep: () => empty,
},
navigation: { items: () => [] },
timeline: { turnOrder: [], turns: new Map() },
legacy: {
nodes: settled,
runningCalls: running,
partial: null,
turnTimings: new Map(),
turnEnds: new Map(),
},
}
}
/** Build the Session event window that projects settled root Tool calls into Chat. */
export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly SessionLiveEventEntry[] {
const firstTime = nodes[0]?.callTime ?? nodes[0]?.time ?? 0
const entries: SessionLiveEventEntry[] = [
{
type: 'event',
event: {
seq: 1,
time: firstTime - 2,
type: 'turn/start',
data: { turn: 1 },
},
},
{
type: 'event',
event: {
seq: 2,
time: firstTime - 1,
type: 'step/start',
data: { turn: 1, step: 1 },
},
},
]
for (const [index, node] of nodes.entries()) {
if (node.call === null) throw new Error(`tool fixture "${node.callId}" requires its call event`)
const callSeq = 3 + index * 2
const callEntry: SessionLiveEventEntry = {
type: 'event',
event: {
seq: callSeq,
time: node.callTime ?? node.time - 1,
type: 'tool/call',
data: {
turn: 1,
step: 1,
callId: node.callId,
name: node.call.name,
arguments: node.call.argsRaw,
},
} as unknown as SessionLiveEventEntry['event'],
}
entries.push(callEntry)
const resultEntry: SessionLiveEventEntry = {
type: 'event',
event: {
seq: callSeq + 1,
time: node.time,
type: 'tool/result',
data: jsonFixture({
turn: 1,
step: 1,
message: {
id: `result-${node.callId}`,
role: 'user',
source: { kind: 'tool', callId: node.callId },
content: [{
type: 'tool-result',
toolCallId: node.callId,
content: node.content.map(block => ({ ...block })),
isError: node.isError,
}],
},
...(node.error === undefined ? {} : { error: node.error }),
...(node.meta === undefined ? {} : { meta: node.meta }),
}),
surfaceOp: 'append',
} as unknown as SessionLiveEventEntry['event'],
}
entries.push(resultEntry)
}
return entries
}
/**
* Bind ui-tool's details renderer to the conversation slot callback shape.
* @param t - conversation locale seat used by Tool cards.
* @param generation - optional Connection generation carrying the Host home.
* @returns a direct-test renderSlot implementation.
*/
export function renderToolDetails(
t: TranslateNS<'conversation'>,
generation?: ConnectionGeneration,
): DetailsSlotProps['renderSlot'] {
return (_key, owner) => {
// PropsRenderSlots keeps its key generic even for this one-key share;
// recover the concrete owner selected by the adapter's fixed slot.
const details = owner as unknown as DetailsToolOwnerProps
return <ToolDetails
block={details.block}
cwd={details.cwd}
useConnectionGeneration={selector => selector(generation)}
t={t}
/>
}
}