api-proxy-view.spec.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  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 '@deepseek-ai/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, createMessage, createToolResultMessage, createUserMessage } 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. /** Append a production-shaped human prompt to the session surface. */
  35. function appendUserText(session: Session, text: string): SessionEvent {
  36. return session.append('user/message', createUserMessage({
  37. content: [{ type: 'text', text }], source: { kind: 'user' },
  38. }), { surfaceOp: 'append' })
  39. }
  40. /** Append a production-shaped assistant message to the session surface. */
  41. function appendAssistantText(session: Session, text: string, step: number): SessionEvent {
  42. return session.append('assistant/message', {
  43. turn: 1,
  44. step,
  45. message: createMessage({
  46. role: 'assistant',
  47. content: [{ type: 'text', text }],
  48. source: { kind: 'model', provider: 'p', model: 'm' },
  49. }),
  50. }, { surfaceOp: 'append' })
  51. }
  52. /**
  53. * Append a plugin-owned log-only event. The host proxy is projection-only, so it
  54. * declares no compaction vocabulary; the cast writes the real event shape without
  55. * depending on the owning package.
  56. */
  57. function appendExtension(session: Session, type: string, data: unknown): SessionEvent {
  58. return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data)
  59. }
  60. async function harness(): Promise<{ ctx: Context }> {
  61. const ctx = new Context()
  62. await ctx.plugin(SessionStore)
  63. await ctx.plugin(SystemPrompt, { persona: '' })
  64. await ctx.plugin(ToolRegistry)
  65. await ctx.plugin(UserInteractionService)
  66. await ctx.plugin(AgentRegistry)
  67. ctx.tools.register(tool('gen', {
  68. presentCall: () => ({ card: 'generic', title: 'gen call' }),
  69. presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
  70. }))
  71. ctx.tools.register(tool('term', {
  72. presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
  73. presentResult: () => ({ card: 'terminal', output: 'done' }),
  74. }))
  75. ctx.tools.register(tool('diffy', {
  76. presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
  77. }))
  78. ctx.tools.register(tool('call-only', {
  79. presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
  80. }))
  81. ctx.tools.register(tool('plain', {}))
  82. ctx.tools.register(tool('boom', {
  83. presentCall: () => { throw new Error('presenter exploded') },
  84. }))
  85. return { ctx }
  86. }
  87. /** Drain frames from an open mux stream until `count` session/event frames arrived. */
  88. async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
  89. const frames: MuxFrame[] = []
  90. for await (const frame of iterable) {
  91. frames.push(frame.payload)
  92. if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
  93. }
  94. return frames
  95. }
  96. describe('mux live view computation', () => {
  97. it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
  98. const { ctx } = await harness()
  99. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  100. const abort = new AbortController()
  101. const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
  102. const collected = collect(stream, 9, abort)
  103. const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
  104. const session = ctx.sessions.create()
  105. session.append('turn/start', { turn: 1 })
  106. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
  107. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
  108. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
  109. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
  110. session.append('tool/result', {
  111. turn: 1, step: 1,
  112. message: createToolResultMessage({
  113. callId: CallId('c-call-only'),
  114. content: [{ type: 'text', text: rawResult }],
  115. isError: false,
  116. }),
  117. }, { surfaceOp: 'append' })
  118. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
  119. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
  120. session.append('tool/result', {
  121. turn: 1, step: 1,
  122. message: createToolResultMessage({
  123. callId: CallId('c-gen'),
  124. content: [{ type: 'text', text: 'ok' }],
  125. isError: false,
  126. }),
  127. }, { surfaceOp: 'append' })
  128. const frames = await collected
  129. const events = frames.filter(f => f.type === 'session/event')
  130. const byCall = new Map(events
  131. .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
  132. .map(f => [
  133. `${f.event.type}:${f.event.type === 'tool/call'
  134. ? f.event.data.callId
  135. : (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
  136. f,
  137. ]))
  138. expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
  139. expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
  140. expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
  141. expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
  142. for: 'call',
  143. view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
  144. })
  145. const callOnlyResult = byCall.get('tool/result:c-call-only')
  146. expect('view' in (callOnlyResult ?? {})).toBe(false)
  147. const serializedResult = JSON.stringify(callOnlyResult)
  148. expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
  149. expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
  150. // No presenter → the frame carries no view property at all.
  151. expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
  152. // Throwing presenter → soft-fall: event ships, no view.
  153. expect(byCall.get('tool/call:c-boom')).toBeDefined()
  154. expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
  155. // Result pairing through the live table: presentResult saw the call's args.
  156. expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
  157. })
  158. it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
  159. const { ctx } = await harness()
  160. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  161. const session = ctx.sessions.create()
  162. // history resolves the agent first; a live structural stub is enough (only
  163. // .session is read on this path).
  164. ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
  165. session.append('turn/start', { turn: 1 })
  166. session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
  167. // meta rides through to presentResult's ToolResult (the spread arm).
  168. session.append('tool/result', {
  169. turn: 1, step: 1,
  170. message: createToolResultMessage({
  171. callId: CallId('h-term'),
  172. content: [{ type: 'text', text: 'ok' }],
  173. isError: false,
  174. }),
  175. meta: { n: 1 },
  176. }, { surfaceOp: 'append' })
  177. // Unpaired result: no tool/call with this id anywhere in the page.
  178. session.append('tool/result', {
  179. turn: 1, step: 1,
  180. message: createToolResultMessage({
  181. callId: CallId('h-orphan'),
  182. content: [{ type: 'text', text: 'x' }],
  183. isError: false,
  184. }),
  185. }, { surfaceOp: 'append' })
  186. // Paired, but the call's stored arguments do not parse: backscan soft-falls.
  187. session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
  188. session.append('tool/result', {
  189. turn: 1, step: 1,
  190. message: createToolResultMessage({
  191. callId: CallId('h-bad'),
  192. content: [{ type: 'text', text: 'y' }],
  193. isError: false,
  194. }),
  195. }, { surfaceOp: 'append' })
  196. // Presenterless tool: pairing succeeds but presentResult is absent.
  197. session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
  198. session.append('tool/result', {
  199. turn: 1, step: 1,
  200. message: createToolResultMessage({
  201. callId: CallId('h-plain'),
  202. content: [{ type: 'text', text: 'z' }],
  203. isError: false,
  204. }),
  205. }, { surfaceOp: 'append' })
  206. const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
  207. expect(response.result.ok).toBe(true)
  208. if (!response.result.ok) throw new Error('unreachable')
  209. const entries = response.result.value.events
  210. const byKey = new Map(entries
  211. .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
  212. .map(entry => [
  213. `${entry.event.type}:${entry.event.type === 'tool/call'
  214. ? entry.event.data.callId
  215. : (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
  216. entry,
  217. ]))
  218. expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
  219. expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
  220. expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
  221. expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
  222. expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
  223. })
  224. it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
  225. const { ctx } = await harness()
  226. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  227. const session = ctx.sessions.create()
  228. ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
  229. session.append('turn/start', { turn: 1 })
  230. const first = appendUserText(session, 'first prompt')
  231. appendAssistantText(session, 'first reply', 1)
  232. const third = appendUserText(session, 'second prompt')
  233. appendAssistantText(session, 'second reply', 2)
  234. const shadowed = [...session.surface.nodes]
  235. // A compaction transaction: a log-only summary record immediately followed by the
  236. // replacement that shadows the range.
  237. const summary = appendExtension(session, 'compact/summary', {
  238. summary: [{ type: 'text', text: 'summary' }],
  239. shadowedRange: { start: shadowed[0], end: shadowed.at(-1) },
  240. shadowedSeqs: shadowed,
  241. shadowedTokenCount: 0,
  242. provider: 'p',
  243. model: 'm',
  244. })
  245. session.append('user/message', createUserMessage({
  246. content: [{ type: 'text', text: '<context_checkpoint>summary</context_checkpoint>' }],
  247. source: { kind: 'plugin', plugin: 'compact' },
  248. }), {
  249. surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number },
  250. sourceEventSeqs: [...shadowed, summary.seq],
  251. })
  252. const response = await api.sessions.history({
  253. rpcId: RpcId('t-hist-compact'),
  254. payload: { sessionId: session.id, maxMessages: 2 },
  255. })
  256. if (!response.result.ok) throw new Error('unreachable')
  257. const page = response.result.value.events.map(entry => entry.event)
  258. // Two append-origin messages fill the page even though a replacement copy of
  259. // the same event type sits in the window: the copy is model-only.
  260. const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')
  261. expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
  262. expect(page.some(event => event.seq === first.seq)).toBe(false)
  263. expect(response.result.value.hasMore).toBe(true)
  264. // The range stays contiguous, so the checkpoint's summary record is readable on
  265. // the same page as the checkpoint itself.
  266. const summaryIndex = page.findIndex(event => event.seq === summary.seq)
  267. expect(summaryIndex).toBeGreaterThan(-1)
  268. expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1)
  269. expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index))
  270. })
  271. it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
  272. const { ctx } = await harness()
  273. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  274. const abort = new AbortController()
  275. const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
  276. let session: Session | undefined
  277. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  278. session = inner.sessions.create('session-doomed' as SessionId)
  279. }, { inject: ['sessions'] }))
  280. session?.append('turn/start', { turn: 1 })
  281. session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
  282. // Disposing the owning fiber detaches the session mid-stream; the
  283. // session/disposed listener must clear its open-call table entry.
  284. await fiber.dispose()
  285. const frames = await collect(stream, 2, abort)
  286. const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
  287. expect(call?.type === 'session/event' && call.view?.for).toBe('call')
  288. })
  289. it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
  290. const { ctx } = await harness()
  291. const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
  292. const abort = new AbortController()
  293. const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
  294. const collected = collect(stream, 4, abort)
  295. const session = ctx.sessions.create()
  296. session.append('turn/start', { turn: 1 })
  297. session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
  298. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  299. // The turn/end above cleared the live table; pairing must fall back to
  300. // scanning the session's in-memory events.
  301. session.append('tool/result', {
  302. turn: 1, step: 1,
  303. message: createToolResultMessage({
  304. callId: CallId('c-late'),
  305. content: [{ type: 'text', text: 'ok' }],
  306. isError: false,
  307. }),
  308. }, { surfaceOp: 'append' })
  309. const frames = await collected
  310. const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
  311. expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
  312. })
  313. })