1
0

request-reconstruction.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. /**
  2. * Loop-level reconstructability: every request the loop sends is a pure function of the
  3. * session log — messages derive at the step/start boundary and the header is the latest
  4. * request/header snapshot. Each request extends its predecessor unless a logged compaction
  5. * replacement or header change explains the difference.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { Context } from 'cordis'
  9. import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  10. import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
  11. import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
  12. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  13. import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
  14. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  15. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  16. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  17. async function harness(adapter: MockAdapter, persona = 'stable base') {
  18. const ctx = new Context()
  19. await ctx.plugin(LlmService)
  20. await ctx.plugin(SessionStore)
  21. await ctx.plugin(SystemPrompt, { persona })
  22. await ctx.plugin(ToolRegistry)
  23. await ctx.plugin(AgentRegistry)
  24. await ctx.plugin(AgentLoop, { agents: [] })
  25. ctx.llm.registerAdapter(['mock'], adapter)
  26. return ctx
  27. }
  28. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  29. return new Promise((resolve) => {
  30. const dispose = ctx.on('agent/status', (subject, status) => {
  31. if (subject === agent && status === 'idle') {
  32. dispose()
  33. resolve()
  34. }
  35. })
  36. })
  37. }
  38. function send(agent: Agent, text: string) {
  39. agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
  40. }
  41. /** Assert `previous` is a strict value-prefix of `current`. */
  42. function expectPrefixExtension(previous: GenerateOptions, current: GenerateOptions) {
  43. expect(current.messages.length).toBeGreaterThan(previous.messages.length)
  44. expect(current.messages.slice(0, previous.messages.length)).toEqual([...previous.messages])
  45. expect(current.system).toEqual(previous.system)
  46. expect(current.tools).toEqual(previous.tools)
  47. }
  48. function registerEcho(ctx: Context) {
  49. ctx.tools.register(defineContentToolFixture({
  50. name: 'echo',
  51. description: 'echo back',
  52. parameters: { text: { type: 'string' } },
  53. async execute(args) {
  54. return [{ type: 'text', text: `echo: ${String(args.text)}` }]
  55. },
  56. }))
  57. }
  58. describe('request stability across the loop', () => {
  59. it('each step request within a turn append-extends the previous, frozen end to end', async () => {
  60. const adapter = new MockAdapter([
  61. toolCallResponse('c1', 'echo', { text: 'one' }, 'first'),
  62. toolCallResponse('c2', 'echo', { text: 'two' }, 'second'),
  63. textResponse('done'),
  64. ])
  65. const ctx = await harness(adapter)
  66. registerEcho(ctx)
  67. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  68. send(agent, 'go')
  69. await waitForIdle(ctx, agent)
  70. expect(adapter.requests).toHaveLength(3)
  71. expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
  72. expectPrefixExtension(adapter.requests[1]!, adapter.requests[2]!)
  73. for (const request of adapter.requests) {
  74. expect(Object.isFrozen(request)).toBe(true)
  75. expect(Object.isFrozen(request.messages)).toBe(true)
  76. }
  77. // One anchoring header snapshot; no further header events (nothing changed).
  78. const headerEvents = agent.session.events.filter(e => e.type === 'request/header')
  79. expect(headerEvents).toHaveLength(1)
  80. expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial')
  81. })
  82. it('a later turn append-extends the previous turn (one conversation, one log)', async () => {
  83. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  84. const ctx = await harness(adapter)
  85. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  86. send(agent, 'first')
  87. await waitForIdle(ctx, agent)
  88. send(agent, 'second')
  89. await waitForIdle(ctx, agent)
  90. expect(adapter.requests).toHaveLength(2)
  91. expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
  92. })
  93. it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
  94. const reasoning = {
  95. efforts: [
  96. { id: ReasoningEffortId('high'), name: 'High' },
  97. { id: ReasoningEffortId('max'), name: 'Max' },
  98. ],
  99. defaultEffort: ReasoningEffortId('high'),
  100. }
  101. const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
  102. const ctx = await harness(adapter)
  103. const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
  104. ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => {
  105. const config = await next()
  106. return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
  107. })
  108. send(agent, 'first')
  109. await waitForIdle(ctx, agent)
  110. send(agent, 'second')
  111. await waitForIdle(ctx, agent)
  112. expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([
  113. ReasoningEffortId('high'),
  114. ReasoningEffortId('max'),
  115. ])
  116. const headers = agent.session.events.filter(event => event.type === 'request/header')
  117. expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([
  118. ReasoningEffortId('high'),
  119. ReasoningEffortId('max'),
  120. ])
  121. expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
  122. for (const [model, effort] of [
  123. ['mock', ReasoningEffortId('max')],
  124. ['replacement', ReasoningEffortId('high')],
  125. ] as const) {
  126. const resumedAdapter = new MockAdapter([textResponse('resumed')], reasoning)
  127. const resumedCtx = await harness(resumedAdapter)
  128. const resumedHandle = await resumedCtx.agents.create({
  129. sessionId: SessionId(`effort-${model}`),
  130. seed: structuredClone(agent.session.events),
  131. agentOptions: { provider: 'mock', model },
  132. })
  133. send(resumedHandle.agent, 'resumed')
  134. await waitForIdle(resumedCtx, resumedHandle.agent)
  135. expect(resumedAdapter.requests[0]?.model).toBe(model)
  136. expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(effort)
  137. const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header')
  138. expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(effort)
  139. expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
  140. }
  141. })
  142. it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => {
  143. const ctx = new Context()
  144. await ctx.plugin(LlmService)
  145. await ctx.plugin(SessionStore)
  146. await ctx.plugin(SystemPrompt, { persona: 'stable base' })
  147. await ctx.plugin(ToolRegistry)
  148. await ctx.plugin(AgentRegistry)
  149. await ctx.plugin(AgentLoop, { agents: [] })
  150. const started = Promise.withResolvers<undefined>()
  151. const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
  152. const first = new class extends MockAdapter {
  153. override async resolveModel(
  154. provider: string,
  155. model: string,
  156. _signal?: AbortSignal,
  157. ): Promise<LlmResolvedModelInfo> {
  158. started.resolve(undefined)
  159. return {
  160. provider,
  161. id: model,
  162. name: model,
  163. reasoning: await reasoning.promise,
  164. }
  165. }
  166. }([textResponse('first')])
  167. const second = new MockAdapter([textResponse('second')], {
  168. efforts: [{ id: ReasoningEffortId('max'), name: 'Max' }],
  169. defaultEffort: ReasoningEffortId('max'),
  170. })
  171. const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
  172. const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
  173. send(agent, 'go')
  174. await started.promise
  175. disposeFirst()
  176. ctx.llm.registerAdapter(['mock'], second)
  177. reasoning.resolve({
  178. efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
  179. defaultEffort: ReasoningEffortId('high'),
  180. })
  181. await waitForIdle(ctx, agent)
  182. expect(first.requests.map(request => request.reasoningEffort)).toEqual([
  183. ReasoningEffortId('high'),
  184. ])
  185. expect(second.requests).toHaveLength(0)
  186. const headers = agent.session.events.filter(event => event.type === 'request/header')
  187. expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
  188. })
  189. it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => {
  190. const started = Promise.withResolvers<AbortSignal>()
  191. const adapter = new class extends MockAdapter {
  192. override resolveModel(
  193. _provider: string,
  194. _model: string,
  195. signal?: AbortSignal,
  196. ): Promise<never> {
  197. if (signal === undefined) return Promise.reject(new Error('missing reasoning signal'))
  198. started.resolve(signal)
  199. return new Promise((_resolve, reject) => {
  200. if (signal.aborted) {
  201. reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
  202. return
  203. }
  204. signal.addEventListener('abort', () => {
  205. reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
  206. }, { once: true })
  207. })
  208. }
  209. }([])
  210. const ctx = await harness(adapter)
  211. const handle = await ctx.agents.create({
  212. sessionId: SessionId('reasoning-dispose'),
  213. agentOptions: { provider: 'mock', model: 'mock' },
  214. })
  215. send(handle.agent, 'go')
  216. const signal = await started.promise
  217. await handle.dispose()
  218. expect(signal.aborted).toBe(true)
  219. expect(handle.agent.status).toBe('idle')
  220. expect(adapter.requests).toHaveLength(0)
  221. expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false)
  222. })
  223. it.each(['plain error', 'LLM error'] as const)(
  224. 'does not swallow a %s from exact-model resolution',
  225. async (kind) => {
  226. const failure = kind === 'plain error'
  227. ? new Error('reasoning metadata failed')
  228. : new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT')
  229. const adapter = new class extends MockAdapter {
  230. override resolveModel(): Promise<never> {
  231. return Promise.reject(failure)
  232. }
  233. }([])
  234. const ctx = await harness(adapter)
  235. const errors: Error[] = []
  236. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  237. if (error instanceof Error) errors.push(error)
  238. })
  239. const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
  240. provider: 'mock',
  241. model: 'mock',
  242. })
  243. send(agent, 'go')
  244. await waitForIdle(ctx, agent)
  245. expect(errors).toContain(failure)
  246. expect(adapter.requests).toHaveLength(0)
  247. },
  248. )
  249. it('lets a short-circuiting llm/stream listener own an unregistered route', async () => {
  250. const ctx = new Context()
  251. await ctx.plugin(LlmService)
  252. await ctx.plugin(SessionStore)
  253. await ctx.plugin(SystemPrompt, { persona: 'stable base' })
  254. await ctx.plugin(ToolRegistry)
  255. await ctx.plugin(AgentRegistry)
  256. await ctx.plugin(AgentLoop, { agents: [] })
  257. let observed: GenerateOptions | undefined
  258. ctx.on('llm/stream', (options) => {
  259. observed = options
  260. return (async function* () {
  261. yield* textResponse('owned')
  262. })()
  263. })
  264. const agent = ctx.agentLoop.create(SessionId('listener-owned'), {
  265. provider: 'listener',
  266. model: 'virtual',
  267. })
  268. send(agent, 'go')
  269. await waitForIdle(ctx, agent)
  270. expect(observed).toMatchObject({ provider: 'listener', model: 'virtual' })
  271. expect(agent.session.requestHeader()?.config).toEqual({
  272. provider: 'listener',
  273. model: 'virtual',
  274. })
  275. expect(agent.session.deriveMessages().at(-1)?.content).toContainEqual({
  276. type: 'text',
  277. text: 'owned',
  278. })
  279. })
  280. it('a compaction replace rewrites the resend, and the log explains it', async () => {
  281. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  282. const ctx = await harness(adapter)
  283. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  284. send(agent, 'first')
  285. await waitForIdle(ctx, agent)
  286. // A pre-step listener compacts turn 1's history before turn 2's step —
  287. // the sanctioned surface rewrite, landing OUTSIDE the step.
  288. const preStep = ctx.on('agent/step', () => {
  289. preStep()
  290. const session = agent.session
  291. const nodes = session.surface.nodes
  292. session.append('user/message', {
  293. content: [{ type: 'text', text: '[summary of turn 1]' }],
  294. source: { kind: 'plugin', plugin: 'test-compact' },
  295. }, {
  296. surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
  297. sourceEventSeqs: [nodes[0]!, nodes[1]!],
  298. })
  299. })
  300. send(agent, 'second')
  301. await waitForIdle(ctx, agent)
  302. const second = adapter.requests[1]!
  303. // The rewritten history: summary replaces turn 1's user+assistant pair.
  304. expect(second.messages[0]!.content.some(b => b.type === 'text' && b.text.includes('[summary of turn 1]'))).toBe(true)
  305. // No header event beyond the anchor: the replace is itself in the log.
  306. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  307. })
  308. it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
  309. const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
  310. const ctx = await harness(adapter)
  311. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  312. send(agent, 'first')
  313. await waitForIdle(ctx, agent)
  314. send(agent, 'second')
  315. await waitForIdle(ctx, agent)
  316. // Identical assembly re-rendered per step is NOT a change.
  317. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  318. ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' })
  319. send(agent, 'third')
  320. await waitForIdle(ctx, agent)
  321. const snapshots = agent.session.events.filter(e => e.type === 'request/header')
  322. expect(snapshots).toHaveLength(2)
  323. expect(snapshots[1]?.data.reason).toBe('change')
  324. expect(adapter.requests[2]!.system).toContain('new guidance')
  325. // History is preserved across the change — only the header moved.
  326. expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length)
  327. })
  328. it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => {
  329. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  330. const ctx = await harness(adapter)
  331. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  332. let injected = false
  333. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
  334. if (!injected) {
  335. injected = true
  336. agent.inject({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })
  337. }
  338. return next()
  339. })
  340. send(agent, 'first')
  341. await waitForIdle(ctx, agent)
  342. const first = adapter.requests[0]!
  343. // The inject landed in the log after the boundary: not in THIS request…
  344. expect(first.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(false)
  345. expect(agent.session.events.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin')).toBe(true)
  346. send(agent, 'second')
  347. await waitForIdle(ctx, agent)
  348. // …but in the next one, at its logged position.
  349. const second = adapter.requests[1]!
  350. expect(second.messages.some(m => m.content.some(b => b.type === 'text' && b.text.includes('[late context]')))).toBe(true)
  351. })
  352. it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => {
  353. const adapter = new MockAdapter([textResponse('one')])
  354. const ctx = await harness(adapter)
  355. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  356. const errors: Error[] = []
  357. ctx.on('agent/error', (_agent, _turn, _step, error) => {
  358. if (error instanceof Error) errors.push(error)
  359. })
  360. ctx.on('llm/stream', (options, next) => {
  361. // The historical failure mode this design kills: a listener rewriting
  362. // request content in place. The freeze turns it into a loud error.
  363. options.messages.push({ role: 'user', content: [{ type: 'text', text: 'sneaky' }] })
  364. return next()
  365. })
  366. send(agent, 'go')
  367. await waitForIdle(ctx, agent)
  368. expect(errors).toHaveLength(1)
  369. expect(errors[0]!.message).toMatch(/not extensible|frozen|read only|readonly/i)
  370. })
  371. it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
  372. const adapter = new MockAdapter([textResponse('one')])
  373. const ctx = await harness(adapter)
  374. const agent = ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' })
  375. send(agent, 'first')
  376. await waitForIdle(ctx, agent)
  377. // Second generation: a new agent whose session is seeded with the first
  378. // one's full log (the resume/fork path).
  379. const adapter2 = new MockAdapter([textResponse('two')])
  380. const ctx2 = await harness(adapter2)
  381. const handle = await ctx2.agents.create({
  382. sessionId: SessionId('gen2-session'),
  383. seed: [...agent.session.events],
  384. agentOptions: { provider: 'mock', model: 'mock' },
  385. })
  386. const agent2 = handle.agent
  387. send(agent2, 'second')
  388. await waitForIdle(ctx2, agent2)
  389. const snapshots = agent2.session.events.filter(e => e.type === 'request/header')
  390. expect(snapshots).toHaveLength(2)
  391. expect(snapshots[1]?.data.reason).toBe('resume')
  392. // Identical header across the restart: byte-identical continuation.
  393. expect(adapter2.requests[0]!.system).toEqual(adapter.requests[0]!.system)
  394. expectPrefixExtension(adapter.requests[0]!, adapter2.requests[0]!)
  395. })
  396. it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => {
  397. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  398. const ctx = await harness(adapter)
  399. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  400. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => {
  401. const config = await next()
  402. // next() resolves the SAME frozen seed — in-place shaping after
  403. // delegation is unrepresentable, so a "mutate what next() returned"
  404. // listener cannot desync the log from the request (nor reach the
  405. // session's cached header fold, which is deep-cloned away and itself
  406. // frozen).
  407. expect(Object.isFrozen(config)).toBe(true)
  408. expect(() => { (config as { temperature?: number }).temperature = 0.9 }).toThrow(TypeError)
  409. return config
  410. })
  411. send(agent, 'first')
  412. await waitForIdle(ctx, agent)
  413. send(agent, 'second')
  414. await waitForIdle(ctx, agent)
  415. // No changed snapshot was logged (nothing really changed), and the session's own
  416. // fold is immutable state.
  417. expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
  418. expect(Object.isFrozen(agent.session.requestHeader())).toBe(true)
  419. expect(adapter.requests[1]!.temperature).toBeUndefined()
  420. })
  421. it('THEOREM: every request rebuilds byte-equal from the session log alone', async () => {
  422. const adapter = new MockAdapter([
  423. toolCallResponse('c1', 'echo', { text: 'one' }, 'calling'),
  424. textResponse('done'),
  425. textResponse('after change'),
  426. ])
  427. const ctx = await harness(adapter)
  428. registerEcho(ctx)
  429. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  430. send(agent, 'go')
  431. await waitForIdle(ctx, agent)
  432. ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
  433. ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
  434. ...await next(), temperature: 0.5, maxTokens: 99, stop: ['<END>'],
  435. }))
  436. send(agent, 'again')
  437. await waitForIdle(ctx, agent)
  438. expect(adapter.requests).toHaveLength(3)
  439. const events = agent.session.events
  440. const stepStarts = events.filter(e => e.type === 'step/start')
  441. expect(stepStarts).toHaveLength(3)
  442. adapter.requests.forEach((request, index) => {
  443. const stepStart = stepStarts[index]!
  444. // Messages: the derivation over the log prefix strictly before this
  445. // step's step/start — rebuilt here through a completely fresh Session.
  446. const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
  447. expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
  448. // Header: the latest request/header snapshot up to this step's dispatch
  449. // (its header event sits between step/start and the first chunk).
  450. const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
  451. const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
  452. expect(request.model).toBe(header.config.model)
  453. expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
  454. expect(request.system).toEqual(header.system)
  455. expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
  456. expect(request.temperature).toBe(header.config.temperature)
  457. expect(request.maxTokens).toBe(header.config.maxTokens)
  458. expect(request.stop).toEqual(header.config.stop)
  459. })
  460. })
  461. })