review-fixes.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService, { ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  7. import AgentRegistry from '@deepseek-ai/dsh-agent'
  8. import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
  9. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  10. /**
  11. * Regression tests for the findings of the first architecture review
  12. * (Codex + sub-agent, post phase-1). Each describe block names the finding.
  13. */
  14. async function harness(adapter: MockAdapter) {
  15. const ctx = new Context()
  16. await ctx.plugin(LlmService)
  17. await ctx.plugin(SessionStore)
  18. await ctx.plugin(SystemPrompt)
  19. await ctx.plugin(ToolRegistry)
  20. await ctx.plugin(AgentRegistry)
  21. await ctx.plugin(AgentLoop, { agents: [] })
  22. ctx.llm.registerAdapter(['mock'], adapter)
  23. return ctx
  24. }
  25. function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
  26. return new Promise((resolve) => {
  27. const dispose = ctx.on('agent/status', (subject, status) => {
  28. if (subject === agent && status === 'idle') {
  29. dispose()
  30. resolve()
  31. }
  32. })
  33. })
  34. }
  35. function send(agent: LoopAgent, text: string) {
  36. agent.send([{ type: 'text', text }])
  37. }
  38. describe('HIGH: session log records what agent/step-result actually produced', () => {
  39. it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => {
  40. const adapter = new MockAdapter([textResponse('original'), textResponse('done')])
  41. const ctx = await harness(adapter)
  42. const executed: string[] = []
  43. ctx.tools.register(defineTool({
  44. name: 'injected-tool',
  45. description: '',
  46. parameters: {},
  47. async execute() {
  48. executed.push('injected-tool')
  49. return [{ type: 'text', text: 'ran' }]
  50. },
  51. }))
  52. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  53. // Plugin rewrites the message: replaces the text AND adds a tool call.
  54. let rewritten = false
  55. ctx.on('agent/step-result', async (_agent, _turn, _step, _message, next) => {
  56. if (rewritten) return next()
  57. rewritten = true
  58. return {
  59. role: 'assistant' as const,
  60. content: [
  61. { type: 'text' as const, text: 'rewritten' },
  62. { type: 'tool-call' as const, id: 'c-injected', name: 'injected-tool', arguments: '{}' },
  63. ],
  64. }
  65. })
  66. send(agent, 'go')
  67. await waitForIdle(ctx, agent)
  68. // the injected tool call was dispatched…
  69. expect(executed).toEqual(['injected-tool'])
  70. // …and the session log recorded the REWRITTEN message, not the original
  71. const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
  72. expect(JSON.stringify(recorded.data)).toContain('rewritten')
  73. expect(JSON.stringify(recorded.data)).not.toContain('original')
  74. // tool/call + tool/result correlate with the injected call id
  75. const callEvent = agent.session.events.find(e => e.type === 'tool/call')!
  76. if (callEvent.type !== 'tool/call') throw new Error('wrong event type')
  77. expect(callEvent.data.callId).toBe('c-injected')
  78. // derived history shows the rewritten message (replay-correct)
  79. const derived = agent.session.deriveMessages()
  80. expect(JSON.stringify(derived)).toContain('rewritten')
  81. expect(JSON.stringify(derived)).not.toContain('original')
  82. })
  83. })
  84. describe('HIGH: abort during tool execution ends the turn', () => {
  85. it('abort() inside a tool prevents both remaining tools and the next model step', async () => {
  86. const adapter = new MockAdapter([
  87. // model asks for two tool calls in one step
  88. [
  89. { type: 'block-start', index: 0, blockType: 'tool-call' },
  90. { type: 'block-end', index: 0, block: { type: 'tool-call', id: 'c1', name: 'aborter', arguments: '{}' } },
  91. { type: 'block-start', index: 1, blockType: 'tool-call' },
  92. { type: 'block-end', index: 1, block: { type: 'tool-call', id: 'c2', name: 'second', arguments: '{}' } },
  93. { type: 'finish', reason: { kind: 'tool-calls' } },
  94. ] satisfies StreamChunk[],
  95. textResponse('should never be requested'),
  96. ])
  97. const ctx = await harness(adapter)
  98. const executed: string[] = []
  99. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  100. ctx.tools.register(defineTool({
  101. name: 'aborter',
  102. description: '',
  103. parameters: {},
  104. async execute() {
  105. executed.push('aborter')
  106. agent.abort('user interrupt')
  107. return [{ type: 'text', text: 'done' }]
  108. },
  109. }))
  110. ctx.tools.register(defineTool({
  111. name: 'second',
  112. description: '',
  113. parameters: {},
  114. async execute() {
  115. executed.push('second')
  116. return [{ type: 'text', text: 'done' }]
  117. },
  118. }))
  119. const reasons: TurnEndReason[] = []
  120. ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
  121. send(agent, 'go')
  122. await waitForIdle(ctx, agent)
  123. expect(executed).toEqual(['aborter']) // second tool never ran
  124. expect(adapter.requests).toHaveLength(1) // no follow-up model call
  125. expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
  126. })
  127. })
  128. describe('HIGH: steering from late extension points is never stranded', () => {
  129. it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => {
  130. const adapter = new MockAdapter([
  131. toolCallResponse('c1', 'echo', { text: 'x' }),
  132. textResponse('after steering'),
  133. ])
  134. const ctx = await harness(adapter)
  135. ctx.tools.register(defineTool({
  136. name: 'echo',
  137. description: '',
  138. parameters: { text: { type: 'string' } },
  139. async execute(args) {
  140. return [{ type: 'text', text: String(args.text) }]
  141. },
  142. }))
  143. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  144. let steeredOnce = false
  145. ctx.on('agent/step-end', () => {
  146. if (steeredOnce) return
  147. steeredOnce = true
  148. agent.steer([{ type: 'text', text: 'goal reminder from step-end' }])
  149. })
  150. send(agent, 'go')
  151. await waitForIdle(ctx, agent)
  152. expect(adapter.requests).toHaveLength(2)
  153. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step-end')
  154. })
  155. it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
  156. const adapter = new MockAdapter([
  157. textResponse('no tools, would stop here'),
  158. textResponse('continued because of steering'),
  159. ])
  160. const ctx = await harness(adapter)
  161. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  162. let steeredOnce = false
  163. ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
  164. if (!steeredOnce) {
  165. steeredOnce = true
  166. agent.steer([{ type: 'text', text: 'one more thing' }])
  167. }
  168. return next()
  169. })
  170. send(agent, 'go')
  171. await waitForIdle(ctx, agent)
  172. // the default decision was false (no tools), but steering forced step 2
  173. expect(adapter.requests).toHaveLength(2)
  174. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
  175. })
  176. it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => {
  177. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
  178. const ctx = await harness(adapter)
  179. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  180. let steeredOnce = false
  181. ctx.on('agent/turn-end', () => {
  182. if (steeredOnce) return
  183. steeredOnce = true
  184. agent.steer([{ type: 'text', text: 'too late for this turn' }])
  185. })
  186. const turns: number[] = []
  187. ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
  188. send(agent, 'go')
  189. await waitForIdle(ctx, agent)
  190. // the loop chains directly into turn 2 (status never returns to idle in
  191. // between), so the first idle transition means both turns are complete
  192. expect(turns).toEqual([1, 2])
  193. expect(adapter.requests).toHaveLength(2)
  194. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
  195. })
  196. it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
  197. const adapter = new MockAdapter(['hang', textResponse('recovered')])
  198. const ctx = await harness(adapter)
  199. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  200. send(agent, 'go')
  201. await new Promise(r => setTimeout(r, 30))
  202. agent.steer([{ type: 'text', text: 'redirect' }])
  203. agent.abort('user interrupt')
  204. await waitForIdle(ctx, agent)
  205. // a new turn ran with the steering content delivered as a message
  206. expect(adapter.requests).toHaveLength(2)
  207. expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('redirect')
  208. })
  209. })
  210. describe('HIGH: plugin exceptions are contained', () => {
  211. it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
  212. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  213. const ctx = await harness(adapter)
  214. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  215. let threwOnce = false
  216. ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
  217. if (!threwOnce) {
  218. threwOnce = true
  219. throw new Error('broken continuation plugin')
  220. }
  221. return false
  222. })
  223. const errors: Error[] = []
  224. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  225. send(agent, 'first')
  226. await waitForIdle(ctx, agent)
  227. expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
  228. // the loop is still alive: a second send works normally
  229. send(agent, 'second')
  230. await waitForIdle(ctx, agent)
  231. expect(adapter.requests).toHaveLength(2)
  232. expect(agent.status).toBe('idle')
  233. })
  234. it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
  235. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  236. const ctx = await harness(adapter)
  237. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  238. let rejectedOnce = false
  239. ctx.on('session/flush', async () => {
  240. if (!rejectedOnce) {
  241. rejectedOnce = true
  242. throw new Error('disk full')
  243. }
  244. })
  245. const errors: Error[] = []
  246. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  247. send(agent, 'first')
  248. await waitForIdle(ctx, agent)
  249. expect(errors.map(e => e.message)).toEqual(['disk full'])
  250. send(agent, 'second')
  251. await waitForIdle(ctx, agent)
  252. expect(adapter.requests).toHaveLength(2)
  253. })
  254. })
  255. describe('MEDIUM: disposed status is part of the agent/status contract', () => {
  256. it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
  257. const adapter = new MockAdapter(['hang'])
  258. const ctx = await harness(adapter)
  259. let agent!: LoopAgent
  260. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  261. agent = inner.agentLoop.create('scoped', { model: 'mock' })
  262. }, { inject: ['agentLoop'] }))
  263. const statuses: string[] = []
  264. const reasons: TurnEndReason[] = []
  265. ctx.on('agent/status', (_agent, status) => void statuses.push(status))
  266. ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
  267. send(agent, 'go')
  268. await new Promise(r => setTimeout(r, 30))
  269. await fiber.dispose()
  270. await agent.done
  271. expect(statuses).toEqual(['running', 'disposed'])
  272. expect(reasons).toEqual([{ kind: 'disposed' }])
  273. })
  274. it('a throwing agent/status listener cannot break disposal or leak the registry entry', async () => {
  275. const adapter = new MockAdapter(['hang'])
  276. const ctx = await harness(adapter)
  277. let agent!: LoopAgent
  278. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  279. agent = inner.agentLoop.create('scoped', { model: 'mock' })
  280. }, { inject: ['agentLoop'] }))
  281. ctx.on('agent/status', (_agent, status) => {
  282. if (status === 'disposed') throw new Error('broken status listener')
  283. })
  284. send(agent, 'go')
  285. await new Promise(r => setTimeout(r, 30))
  286. await fiber.dispose()
  287. await agent.done // must not hang
  288. expect(agent.status).toBe('disposed')
  289. expect(ctx.agents.get('scoped')).toBeUndefined() // unregistered despite the throw
  290. })
  291. })
  292. describe('MEDIUM: misc registry and config fixes', () => {
  293. it('duplicate adapter registration is rejected', async () => {
  294. const ctx = new Context()
  295. await ctx.plugin(LlmService)
  296. const adapter = new MockAdapter([])
  297. ctx.llm.registerAdapter(['m1'], adapter)
  298. expect(() => ctx.llm.registerAdapter(['m1'], new MockAdapter([])))
  299. .toThrow('already registered')
  300. // the original registration survives the failed attempt
  301. expect(ctx.llm.models()).toEqual(['m1'])
  302. })
  303. it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
  304. const adapter = new MockAdapter([textResponse('never')])
  305. const ctx = await harness(adapter)
  306. const agent = ctx.agentLoop.create('a1', {}) // no model
  307. const errors: Error[] = []
  308. ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
  309. send(agent, 'go')
  310. await waitForIdle(ctx, agent)
  311. expect(errors).toHaveLength(1)
  312. expect(errors[0]!.message).toContain('has no model')
  313. expect(errors[0]!.message).toContain('agent/request')
  314. })
  315. it('the agent/request waterfall can supply the model for a model-less agent', async () => {
  316. const adapter = new MockAdapter([textResponse('routed')])
  317. const ctx = await harness(adapter)
  318. const agent = ctx.agentLoop.create('a1', {}) // no model — router plugin decides
  319. ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
  320. options.model = 'mock'
  321. return next()
  322. })
  323. send(agent, 'go')
  324. await waitForIdle(ctx, agent)
  325. expect(adapter.requests).toHaveLength(1)
  326. expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
  327. })
  328. it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
  329. const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
  330. const ctx = await harness(adapter)
  331. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  332. ctx.tools.register(defineTool({
  333. name: 'noop',
  334. description: '',
  335. parameters: {},
  336. async execute() {
  337. agent.steer([{ type: 'text', text: 's' }], { source: { kind: 'plugin', plugin: 'goal' } })
  338. return []
  339. },
  340. }))
  341. const queuedSources: { source: MessageSource; steering: boolean }[] = []
  342. const steeringSources: MessageSource[] = []
  343. ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
  344. ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
  345. send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
  346. await waitForIdle(ctx, agent)
  347. expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
  348. expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
  349. expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
  350. })
  351. })
  352. describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
  353. it('a forked agent continues turn numbers after the seed log', async () => {
  354. const first = new MockAdapter([textResponse('turn one')])
  355. const ctx = await harness(first)
  356. const agent = ctx.agentLoop.create('a1', { model: 'mock' })
  357. send(agent, 'first')
  358. await waitForIdle(ctx, agent)
  359. // fork: seed a second context's agent with the first session's log
  360. const second = new MockAdapter([textResponse('turn two')])
  361. const ctx2 = new Context()
  362. await ctx2.plugin(LlmService)
  363. await ctx2.plugin(SessionStore)
  364. await ctx2.plugin(SystemPrompt)
  365. await ctx2.plugin(ToolRegistry)
  366. await ctx2.plugin(AgentRegistry)
  367. await ctx2.plugin(AgentLoop, { agents: [] })
  368. ctx2.llm.registerAdapter(['mock'], second)
  369. const seeded = ctx2.sessions.create('forked', [...agent.session.events])
  370. const forked = new LoopAgent(ctx2, 'forked-agent', { model: 'mock' }, seeded)
  371. ctx2.effect(() => forked.start())
  372. const turns: number[] = []
  373. ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
  374. forked.send([{ type: 'text', text: 'continue' }])
  375. await new Promise<void>((resolve) => {
  376. ctx2.on('agent/status', (subject, status) => {
  377. if (subject === forked && status === 'idle') resolve()
  378. })
  379. })
  380. expect(turns).toEqual([2])
  381. })
  382. })
  383. describe('LOW: BlockAssembler and streamBlocks edge cases', () => {
  384. it('ignores deltas arriving after block-end for the same index (malformed stream)', async () => {
  385. const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
  386. const assembler = new BlockAssembler()
  387. assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
  388. assembler.push({ type: 'text-delta', index: 0, text: 'good' })
  389. assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'good' } })
  390. assembler.push({ type: 'text-delta', index: 0, text: ' straggler' })
  391. expect(assembler.blocks()).toEqual([{ type: 'text', text: 'good' }])
  392. })
  393. it('assembles tool-call blocks from deltas without block-end', async () => {
  394. const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
  395. const assembler = new BlockAssembler()
  396. assembler.push({ type: 'tool-call-delta', index: 0, id: 'c9', name: 'echo', argumentsDelta: '{"a"' })
  397. assembler.push({ type: 'tool-call-delta', index: 0, id: 'c9', argumentsDelta: ':1}' })
  398. expect(assembler.blocks()).toEqual([
  399. { type: 'tool-call', id: 'c9', name: 'echo', arguments: '{"a":1}' },
  400. ])
  401. })
  402. it('streamBlocks flushes delta-only blocks at end of stream (matches generate())', async () => {
  403. const ctx = new Context()
  404. await ctx.plugin(LlmService)
  405. const deltaOnly: StreamChunk[] = [
  406. { type: 'text-delta', index: 0, text: 'no ' },
  407. { type: 'text-delta', index: 0, text: 'block-end' },
  408. { type: 'finish', reason: { kind: 'stop' } },
  409. ]
  410. ctx.llm.registerAdapter(['m'], new MockAdapter([deltaOnly, deltaOnly]))
  411. const blocks: ContentBlock[] = []
  412. for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
  413. expect(blocks).toEqual([{ type: 'text', text: 'no block-end' }])
  414. const generated = await ctx.llm.generate({ model: 'm', messages: [] })
  415. expect(generated.message.content).toEqual(blocks)
  416. })
  417. it('streamBlocks preserves stream order when an open block precedes a closed one', async () => {
  418. const ctx = new Context()
  419. await ctx.plugin(LlmService)
  420. // index 0 never gets block-end (delta-only); index 1 closes mid-stream.
  421. const interleaved: StreamChunk[] = [
  422. { type: 'text-delta', index: 0, text: 'first, open' },
  423. { type: 'block-start', index: 1, blockType: 'text' },
  424. { type: 'text-delta', index: 1, text: 'second, closed' },
  425. { type: 'block-end', index: 1, block: { type: 'text', text: 'second, closed' } },
  426. { type: 'finish', reason: { kind: 'stop' } },
  427. ]
  428. ctx.llm.registerAdapter(['m'], new MockAdapter([interleaved, interleaved]))
  429. const blocks: ContentBlock[] = []
  430. for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
  431. expect(blocks).toEqual([
  432. { type: 'text', text: 'first, open' },
  433. { type: 'text', text: 'second, closed' },
  434. ])
  435. // identical to generate()'s assembled order
  436. const generated = await ctx.llm.generate({ model: 'm', messages: [] })
  437. expect(generated.message.content).toEqual(blocks)
  438. })
  439. it('streamBlocks yields closed blocks incrementally once preceding blocks close', async () => {
  440. const ctx = new Context()
  441. await ctx.plugin(LlmService)
  442. const script: StreamChunk[] = [
  443. { type: 'block-start', index: 0, blockType: 'text' },
  444. { type: 'text-delta', index: 0, text: 'a' },
  445. { type: 'block-end', index: 0, block: { type: 'text', text: 'a' } },
  446. { type: 'block-start', index: 1, blockType: 'text' },
  447. { type: 'text-delta', index: 1, text: 'b' },
  448. { type: 'block-end', index: 1, block: { type: 'text', text: 'b' } },
  449. { type: 'finish', reason: { kind: 'stop' } },
  450. ]
  451. ctx.llm.registerAdapter(['m'], new MockAdapter([script]))
  452. const blocks: ContentBlock[] = []
  453. for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
  454. expect(blocks).toEqual([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])
  455. })
  456. })
  457. describe('LOW: discriminated SessionEvent narrows without casts', () => {
  458. it('narrows event.data from event.type', () => {
  459. const session = new Session('s')
  460. const appended: SessionEvent = session.append('tool/call', {
  461. turn: 1, step: 1, callId: 'c1', name: 'echo', arguments: '{}',
  462. })
  463. // compile-time: this switch narrows; runtime: values flow through
  464. switch (appended.type) {
  465. case 'tool/call': {
  466. expect(appended.data.callId).toBe('c1')
  467. expect(appended.data.name).toBe('echo')
  468. break
  469. }
  470. default: throw new Error('wrong narrow')
  471. }
  472. })
  473. })