api-proxy-view.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. /**
  2. * Tool-card view computation over the mux live path: three standard card types
  3. * arrive on the frame, a presenterless tool ships no view field, a call-only
  4. * presenter keeps raw result content out of the view payload, and a throwing
  5. * presenter soft-falls to no view (the event still ships). Result pairing
  6. * works both through the live open-call table and the backscan fallback after
  7. * turn/end cleared it.
  8. */
  9. import { describe, expect, it } from 'vitest'
  10. import { Context } from 'cordis'
  11. import AgentRegistry from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import SessionStore from '@deepseek-ai/dsh-session'
  14. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  15. import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  16. import { CallId, createToolResultMessage } from '@deepseek-ai/dsh-llm'
  17. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  18. import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  19. import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
  20. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  21. import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
  22. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  23. import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
  24. const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
  25. function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
  26. return defineContentToolFixture({
  27. name,
  28. description: `tool ${name}`,
  29. parameters: {},
  30. execute: () => reply(`ran:${name}`),
  31. ...presenters,
  32. })
  33. }
  34. async function harness(): Promise<{ ctx: Context }> {
  35. const ctx = new Context()
  36. await ctx.plugin(SessionStore)
  37. await ctx.plugin(SystemPrompt, { persona: '' })
  38. await ctx.plugin(ToolRegistry)
  39. await ctx.plugin(UserInteractionService)
  40. await ctx.plugin(AgentRegistry)
  41. ctx.tools.register(tool('gen', {
  42. presentCall: () => ({ card: 'generic', title: 'gen call' }),
  43. presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
  44. }))
  45. ctx.tools.register(tool('term', {
  46. presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
  47. presentResult: () => ({ card: 'terminal', output: 'done' }),
  48. }))
  49. ctx.tools.register(tool('diffy', {
  50. presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
  51. }))
  52. ctx.tools.register(tool('call-only', {
  53. presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
  54. }))
  55. ctx.tools.register(tool('plain', {}))
  56. ctx.tools.register(tool('boom', {
  57. presentCall: () => { throw new Error('presenter exploded') },
  58. }))
  59. return { ctx }
  60. }
  61. /** Drain frames from an open mux stream until `count` session/event frames arrived. */
  62. async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
  63. const frames: MuxFrame[] = []
  64. for await (const frame of iterable) {
  65. frames.push(frame.payload)
  66. if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
  67. }
  68. return frames
  69. }
  70. describe('mux live view computation', () => {
  71. it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
  72. const { ctx } = await harness()
  73. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  74. const abort = new AbortController()
  75. const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
  76. const collected = collect(stream, 9, abort)
  77. const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
  78. const session = ctx.sessions.create()
  79. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  80. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
  81. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
  82. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
  83. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
  84. session.append('tool/result', {
  85. turn: 1, step: 1,
  86. message: createToolResultMessage({
  87. callId: CallId('c-call-only'),
  88. content: [{ type: 'text', text: rawResult }],
  89. isError: false,
  90. }),
  91. }, { surfaceOp: 'append' })
  92. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
  93. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
  94. session.append('tool/result', {
  95. turn: 1, step: 1,
  96. message: createToolResultMessage({
  97. callId: CallId('c-gen'),
  98. content: [{ type: 'text', text: 'ok' }],
  99. isError: false,
  100. }),
  101. }, { surfaceOp: 'append' })
  102. const frames = await collected
  103. const events = frames.filter(f => f.type === 'session/event')
  104. const byCall = new Map(events
  105. .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
  106. .map(f => [
  107. `${f.event.type}:${f.event.type === 'tool/call'
  108. ? f.event.data.callId
  109. : (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
  110. f,
  111. ]))
  112. expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
  113. expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
  114. expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
  115. expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
  116. for: 'call',
  117. view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
  118. })
  119. const callOnlyResult = byCall.get('tool/result:c-call-only')
  120. expect('view' in (callOnlyResult ?? {})).toBe(false)
  121. const serializedResult = JSON.stringify(callOnlyResult)
  122. expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
  123. expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
  124. // No presenter → the frame carries no view property at all.
  125. expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
  126. // Throwing presenter → soft-fall: event ships, no view.
  127. expect(byCall.get('tool/call:c-boom')).toBeDefined()
  128. expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
  129. // Result pairing through the live table: presentResult saw the call's args.
  130. expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
  131. })
  132. it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
  133. const { ctx } = await harness()
  134. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  135. const session = ctx.sessions.create()
  136. // history resolves the agent first; a live structural stub is enough (only
  137. // .session is read on this path).
  138. ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
  139. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  140. session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
  141. // meta rides through to presentResult's ToolResult (the spread arm).
  142. session.append('tool/result', {
  143. turn: 1, step: 1,
  144. message: createToolResultMessage({
  145. callId: CallId('h-term'),
  146. content: [{ type: 'text', text: 'ok' }],
  147. isError: false,
  148. }),
  149. meta: { n: 1 },
  150. }, { surfaceOp: 'append' })
  151. // Unpaired result: no tool/call with this id anywhere in the page.
  152. session.append('tool/result', {
  153. turn: 1, step: 1,
  154. message: createToolResultMessage({
  155. callId: CallId('h-orphan'),
  156. content: [{ type: 'text', text: 'x' }],
  157. isError: false,
  158. }),
  159. }, { surfaceOp: 'append' })
  160. // Paired, but the call's stored arguments do not parse: backscan soft-falls.
  161. session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
  162. session.append('tool/result', {
  163. turn: 1, step: 1,
  164. message: createToolResultMessage({
  165. callId: CallId('h-bad'),
  166. content: [{ type: 'text', text: 'y' }],
  167. isError: false,
  168. }),
  169. }, { surfaceOp: 'append' })
  170. // Presenterless tool: pairing succeeds but presentResult is absent.
  171. session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
  172. session.append('tool/result', {
  173. turn: 1, step: 1,
  174. message: createToolResultMessage({
  175. callId: CallId('h-plain'),
  176. content: [{ type: 'text', text: 'z' }],
  177. isError: false,
  178. }),
  179. }, { surfaceOp: 'append' })
  180. const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
  181. expect(response.result.ok).toBe(true)
  182. if (!response.result.ok) throw new Error('unreachable')
  183. const entries = response.result.value.events
  184. const byKey = new Map(entries
  185. .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
  186. .map(entry => [
  187. `${entry.event.type}:${entry.event.type === 'tool/call'
  188. ? entry.event.data.callId
  189. : (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
  190. entry,
  191. ]))
  192. expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
  193. expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
  194. expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
  195. expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
  196. expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
  197. })
  198. it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
  199. const { ctx } = await harness()
  200. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  201. const abort = new AbortController()
  202. const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
  203. let session: Session | undefined
  204. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  205. session = inner.sessions.create('session-doomed' as SessionId)
  206. }, { inject: ['sessions'] }))
  207. session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  208. session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
  209. // Disposing the owning fiber detaches the session mid-stream; the
  210. // session/disposed listener must clear its open-call table entry.
  211. await fiber.dispose()
  212. const frames = await collect(stream, 2, abort)
  213. const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
  214. expect(call?.type === 'session/event' && call.view?.for).toBe('call')
  215. })
  216. it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
  217. const { ctx } = await harness()
  218. const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
  219. const abort = new AbortController()
  220. const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
  221. const collected = collect(stream, 4, abort)
  222. const session = ctx.sessions.create()
  223. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  224. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
  225. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  226. // The turn/end above cleared the live table; pairing must fall back to
  227. // scanning the session's in-memory events.
  228. session.append('tool/result', {
  229. turn: 1, step: 1,
  230. message: createToolResultMessage({
  231. callId: CallId('c-late'),
  232. content: [{ type: 'text', text: 'ok' }],
  233. isError: false,
  234. }),
  235. }, { surfaceOp: 'append' })
  236. const frames = await collected
  237. const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
  238. expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
  239. })
  240. })