tool-details-render.client.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /** Test adapter for the production conversation.details.tool registration. */
  2. import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
  3. import type { SessionEventEntry } from '@deepseek-ai/dsh-api-session-controller/types'
  4. import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
  5. import type {
  6. ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
  7. DetailsToolOwnerProps, RunningToolCall, ToolResultNode,
  8. } from '@deepseek-ai/dsh-client-ui-chat/client'
  9. import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
  10. import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
  11. type TrajectorySnapshot = Parameters<Parameters<DetailsSlotProps['useTrajectory']>[0]>[0]
  12. const emptyTrajectory: TrajectorySnapshot = {
  13. eventNodes: [],
  14. eventLocations: new Map(),
  15. requests: [],
  16. callSchemas: new Map(),
  17. partial: null,
  18. runningCalls: [],
  19. }
  20. /** Stable empty Trajectory source for DetailsPanel fixtures. */
  21. export const useEmptyTrajectory: DetailsSlotProps['useTrajectory'] = selector => selector(emptyTrajectory)
  22. function jsonFixture(value: unknown): JsonValue {
  23. if (!isJsonValue(value)) throw new Error('tool event fixture must be lossless JSON')
  24. return value as JsonValue
  25. }
  26. /** Build the canonical Chat slice consumed by Tool rows and details tests. */
  27. export function toolChatSnapshot(
  28. settled: readonly ConversationNode[] = [],
  29. running: readonly RunningToolCall[] = [],
  30. ): ChatSnapshot {
  31. const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running]
  32. const nodes: ChatConversationViewNode[] = roots.map(root => ({
  33. key: `tool:${root.callId}`,
  34. kind: 'tool-call',
  35. id: root.callId,
  36. target: 'chat',
  37. anchorSeq: 'kind' in root ? root.seq : Number.MAX_SAFE_INTEGER,
  38. location: { kind: 'session' },
  39. visibility: 'visible',
  40. data: { root },
  41. }))
  42. const byKey = new Map(nodes.map(node => [node.key, node]))
  43. const empty: readonly string[] = []
  44. return {
  45. order: nodes.map(node => node.key),
  46. nodes: {
  47. get: key => byKey.get(key),
  48. values: () => nodes,
  49. },
  50. locations: {
  51. getTurn: () => empty,
  52. getStep: () => empty,
  53. },
  54. timeline: { turnOrder: [], turns: new Map() },
  55. legacy: {
  56. nodes: settled,
  57. runningCalls: running,
  58. partial: null,
  59. turnTimings: new Map(),
  60. turnEnds: new Map(),
  61. },
  62. }
  63. }
  64. /** Build the Session event window that projects settled root Tool calls into Chat. */
  65. export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly SessionEventEntry[] {
  66. const firstTime = nodes[0]?.callTime ?? nodes[0]?.time ?? 0
  67. const entries: SessionEventEntry[] = [
  68. {
  69. event: {
  70. seq: 1,
  71. time: firstTime - 2,
  72. type: 'turn/start',
  73. data: { turn: 1 },
  74. },
  75. },
  76. {
  77. event: {
  78. seq: 2,
  79. time: firstTime - 1,
  80. type: 'step/start',
  81. data: { turn: 1, step: 1 },
  82. },
  83. },
  84. ]
  85. for (const [index, node] of nodes.entries()) {
  86. if (node.call === null) throw new Error(`tool fixture "${node.callId}" requires its call event`)
  87. const callSeq = 3 + index * 2
  88. const callEntry: SessionEventEntry = {
  89. event: {
  90. seq: callSeq,
  91. time: node.callTime ?? node.time - 1,
  92. type: 'tool/call',
  93. data: {
  94. turn: 1,
  95. step: 1,
  96. callId: node.callId,
  97. name: node.call.name,
  98. arguments: node.call.argsRaw,
  99. },
  100. },
  101. }
  102. entries.push(callEntry)
  103. const resultEntry: SessionEventEntry = {
  104. event: {
  105. seq: callSeq + 1,
  106. time: node.time,
  107. type: 'tool/result',
  108. data: jsonFixture({
  109. turn: 1,
  110. step: 1,
  111. message: {
  112. id: `result-${node.callId}`,
  113. role: 'user',
  114. source: { kind: 'tool', callId: node.callId },
  115. content: [{
  116. type: 'tool-result',
  117. toolCallId: node.callId,
  118. content: node.content.map(block => ({ ...block })),
  119. isError: node.isError,
  120. }],
  121. },
  122. ...(node.error === undefined ? {} : { error: node.error }),
  123. ...(node.meta === undefined ? {} : { meta: node.meta }),
  124. }),
  125. surfaceOp: 'append',
  126. },
  127. }
  128. entries.push(resultEntry)
  129. }
  130. return entries
  131. }
  132. /**
  133. * Bind ui-tool's details renderer to the conversation slot callback shape.
  134. * @param t - conversation locale seat used by Tool cards.
  135. * @param description - optional Host description so the details card can abbreviate home paths.
  136. * @returns a direct-test renderSlot implementation.
  137. */
  138. export function renderToolDetails(
  139. t: TranslateNS<'conversation'>,
  140. description?: HostDescription,
  141. ): DetailsSlotProps['renderSlot'] {
  142. return (_key, owner) => {
  143. // PropsRenderSlots keeps its key generic even for this one-key share;
  144. // recover the concrete owner selected by the adapter's fixed slot.
  145. const details = owner as unknown as DetailsToolOwnerProps
  146. return <ToolDetails
  147. block={details.block}
  148. cwd={details.cwd}
  149. useHostDescription={selector => selector(description)}
  150. t={t}
  151. />
  152. }
  153. }