@roj-ai/sdk 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/bootstrap.d.ts +36 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/core/agents/agent.d.ts +11 -0
  4. package/dist/core/agents/agent.d.ts.map +1 -1
  5. package/dist/core/agents/agent.js +26 -5
  6. package/dist/core/agents/agent.js.map +1 -1
  7. package/dist/core/agents/state.d.ts +24 -0
  8. package/dist/core/agents/state.d.ts.map +1 -1
  9. package/dist/core/agents/state.js.map +1 -1
  10. package/dist/core/context/state.d.ts.map +1 -1
  11. package/dist/core/context/state.js +4 -0
  12. package/dist/core/context/state.js.map +1 -1
  13. package/dist/core/llm/anthropic.d.ts.map +1 -1
  14. package/dist/core/llm/anthropic.js +49 -8
  15. package/dist/core/llm/anthropic.js.map +1 -1
  16. package/dist/core/llm/model-shortlist-live.test.d.ts +35 -0
  17. package/dist/core/llm/model-shortlist-live.test.d.ts.map +1 -0
  18. package/dist/core/llm/model-shortlist-live.test.js +452 -0
  19. package/dist/core/llm/model-shortlist-live.test.js.map +1 -0
  20. package/dist/core/llm/openrouter-mapping.test.js +18 -0
  21. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  22. package/dist/core/llm/openrouter.d.ts.map +1 -1
  23. package/dist/core/llm/openrouter.js +20 -11
  24. package/dist/core/llm/openrouter.js.map +1 -1
  25. package/dist/core/llm/provider.d.ts +34 -3
  26. package/dist/core/llm/provider.d.ts.map +1 -1
  27. package/dist/core/llm/provider.js.map +1 -1
  28. package/dist/core/llm/reasoning-details.test.d.ts +2 -0
  29. package/dist/core/llm/reasoning-details.test.d.ts.map +1 -0
  30. package/dist/core/llm/reasoning-details.test.js +144 -0
  31. package/dist/core/llm/reasoning-details.test.js.map +1 -0
  32. package/dist/core/llm/state.d.ts +2 -0
  33. package/dist/core/llm/state.d.ts.map +1 -1
  34. package/dist/core/llm/state.js +11 -0
  35. package/dist/core/llm/state.js.map +1 -1
  36. package/dist/core/llm/thinking-blocks.test.d.ts +2 -0
  37. package/dist/core/llm/thinking-blocks.test.d.ts.map +1 -0
  38. package/dist/core/llm/thinking-blocks.test.js +291 -0
  39. package/dist/core/llm/thinking-blocks.test.js.map +1 -0
  40. package/dist/core/preset/index.d.ts +1 -0
  41. package/dist/core/preset/index.d.ts.map +1 -1
  42. package/dist/core/preset/index.js +2 -0
  43. package/dist/core/preset/index.js.map +1 -1
  44. package/dist/core/preset/overrides.d.ts +14 -0
  45. package/dist/core/preset/overrides.d.ts.map +1 -0
  46. package/dist/core/preset/overrides.js +20 -0
  47. package/dist/core/preset/overrides.js.map +1 -0
  48. package/dist/core/reasoning-round-trip.integration.test.d.ts +2 -0
  49. package/dist/core/reasoning-round-trip.integration.test.d.ts.map +1 -0
  50. package/dist/core/reasoning-round-trip.integration.test.js +150 -0
  51. package/dist/core/reasoning-round-trip.integration.test.js.map +1 -0
  52. package/dist/core/sessions/session-manager.d.ts +2 -0
  53. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  54. package/dist/core/sessions/session-manager.js +17 -5
  55. package/dist/core/sessions/session-manager.js.map +1 -1
  56. package/dist/core/sessions/session-overrides.integration.test.d.ts +2 -0
  57. package/dist/core/sessions/session-overrides.integration.test.d.ts.map +1 -0
  58. package/dist/core/sessions/session-overrides.integration.test.js +154 -0
  59. package/dist/core/sessions/session-overrides.integration.test.js.map +1 -0
  60. package/dist/core/sessions/session.d.ts +10 -1
  61. package/dist/core/sessions/session.d.ts.map +1 -1
  62. package/dist/core/sessions/session.js +15 -0
  63. package/dist/core/sessions/session.js.map +1 -1
  64. package/dist/core/sessions/state.d.ts +57 -0
  65. package/dist/core/sessions/state.d.ts.map +1 -1
  66. package/dist/core/sessions/state.js +53 -0
  67. package/dist/core/sessions/state.js.map +1 -1
  68. package/dist/index.d.ts +3 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -1
  71. package/dist/index.js.map +1 -1
  72. package/dist/plugins/services/plugin.d.ts +3 -0
  73. package/dist/plugins/services/plugin.d.ts.map +1 -1
  74. package/dist/plugins/services/plugin.js +50 -14
  75. package/dist/plugins/services/plugin.js.map +1 -1
  76. package/dist/plugins/services/prompt.d.ts.map +1 -1
  77. package/dist/plugins/services/prompt.js +8 -0
  78. package/dist/plugins/services/prompt.js.map +1 -1
  79. package/dist/plugins/services/schema.d.ts +33 -0
  80. package/dist/plugins/services/schema.d.ts.map +1 -1
  81. package/dist/plugins/services/service.d.ts +20 -0
  82. package/dist/plugins/services/service.d.ts.map +1 -1
  83. package/dist/plugins/services/service.js +88 -1
  84. package/dist/plugins/services/service.js.map +1 -1
  85. package/dist/plugins/services/services.integration.test.js +254 -0
  86. package/dist/plugins/services/services.integration.test.js.map +1 -1
  87. package/dist/plugins/session-lifecycle/plugin.d.ts +36 -0
  88. package/dist/plugins/session-lifecycle/plugin.d.ts.map +1 -1
  89. package/dist/plugins/session-lifecycle/plugin.js +53 -1
  90. package/dist/plugins/session-lifecycle/plugin.js.map +1 -1
  91. package/dist/testing/test-harness.d.ts +12 -2
  92. package/dist/testing/test-harness.d.ts.map +1 -1
  93. package/dist/testing/test-harness.js +13 -2
  94. package/dist/testing/test-harness.js.map +1 -1
  95. package/package.json +3 -3
  96. package/src/core/agents/agent.ts +28 -5
  97. package/src/core/agents/state.ts +26 -0
  98. package/src/core/context/state.ts +4 -0
  99. package/src/core/llm/anthropic.ts +71 -10
  100. package/src/core/llm/model-shortlist-live.test.ts +577 -0
  101. package/src/core/llm/openrouter-mapping.test.ts +24 -0
  102. package/src/core/llm/openrouter.ts +28 -12
  103. package/src/core/llm/provider.ts +44 -3
  104. package/src/core/llm/reasoning-details.test.ts +177 -0
  105. package/src/core/llm/state.ts +12 -0
  106. package/src/core/llm/thinking-blocks.test.ts +345 -0
  107. package/src/core/preset/index.ts +3 -0
  108. package/src/core/preset/overrides.ts +24 -0
  109. package/src/core/reasoning-round-trip.integration.test.ts +188 -0
  110. package/src/core/sessions/session-manager.ts +22 -3
  111. package/src/core/sessions/session-overrides.integration.test.ts +210 -0
  112. package/src/core/sessions/session.ts +19 -1
  113. package/src/core/sessions/state.ts +82 -0
  114. package/src/index.ts +9 -1
  115. package/src/plugins/services/plugin.ts +50 -14
  116. package/src/plugins/services/prompt.ts +8 -0
  117. package/src/plugins/services/schema.ts +33 -0
  118. package/src/plugins/services/service.ts +113 -1
  119. package/src/plugins/services/services.integration.test.ts +270 -0
  120. package/src/plugins/session-lifecycle/plugin.ts +59 -1
  121. package/src/testing/test-harness.ts +22 -3
@@ -0,0 +1,345 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { LLMMessage } from '~/core/agents/state.js'
3
+ import { ToolCallId } from '~/core/tools/schema.js'
4
+ import { isOk } from '~/lib/utils/result.js'
5
+ import { AnthropicProvider } from './anthropic.js'
6
+ import type { InferenceRequest, RawInferenceRequest } from './provider.js'
7
+ import { ModelId } from './schema.js'
8
+
9
+ // ============================================================================
10
+ // Extended thinking round trip
11
+ // ============================================================================
12
+ //
13
+ // Anthropic returns `thinking` blocks carrying a `signature` it verifies, and during
14
+ // tool use it requires the block sequence back on the assistant turn that made the
15
+ // tool call. So unlike OpenRouter — where dropping the blocks silently degrades the
16
+ // model — dropping them here is a hard API error the moment thinking is switched on.
17
+ //
18
+ // The blocks are opaque to the SDK. What these tests pin down is that they come back
19
+ // byte-identical and in the position the API expects, and that a response with no
20
+ // thinking produces exactly the request bytes it did before.
21
+
22
+ const testModel = ModelId('claude-sonnet-4-6')
23
+
24
+ /** Shape from the Anthropic docs. Signatures are truncated but structurally real. */
25
+ const THINKING_BLOCKS = [
26
+ {
27
+ type: 'thinking',
28
+ thinking: 'The user is asking about the weather. I should call the tool.',
29
+ signature: 'EqQBCgIYAhIM1gbcDa9GJwZA2b3hGgxBdjrkzLoky3dl1KkiMOYds',
30
+ },
31
+ {
32
+ type: 'redacted_thinking',
33
+ data: 'EmwKAhgBEgy3va3POvlKN51xeoMaDC3sTb2z0opaque',
34
+ },
35
+ ]
36
+
37
+ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
38
+
39
+ const createProvider = (fetchFn?: FetchFn) =>
40
+ new AnthropicProvider({
41
+ apiKey: 'test-key',
42
+ imageProcessor: { resolveContent: async (content) => content },
43
+ defaultModel: String(testModel),
44
+ fetch: fetchFn,
45
+ })
46
+
47
+ const respondWith = (content: unknown[]): FetchFn => async () =>
48
+ new Response(
49
+ JSON.stringify({
50
+ id: 'msg_1',
51
+ model: String(testModel),
52
+ content,
53
+ stop_reason: 'end_turn',
54
+ usage: { input_tokens: 10, output_tokens: 5 },
55
+ }),
56
+ { status: 200, headers: { 'content-type': 'application/json' } },
57
+ )
58
+
59
+ const inferenceRequest = (): InferenceRequest => ({
60
+ model: testModel,
61
+ systemPrompt: 'You are helpful.',
62
+ messages: [{ role: 'user', content: 'What is the weather?' }],
63
+ })
64
+
65
+ const rawRequest = (messages: LLMMessage[]): RawInferenceRequest => ({
66
+ model: testModel,
67
+ systemPrompt: 'You are helpful.',
68
+ messages,
69
+ })
70
+
71
+ /**
72
+ * The assistant message as it reaches the API, after mapping and merging.
73
+ *
74
+ * A trailing user turn is appended because the provider refuses a history that ends on an
75
+ * assistant message — the same reason the real loop always has something after it.
76
+ */
77
+ const assistantBlocks = async (...messages: LLMMessage[]): Promise<unknown[]> => {
78
+ const http = await createProvider().buildHttpRequest(
79
+ rawRequest([{ role: 'user', content: 'Hi' }, ...messages, { role: 'user', content: 'And?' }]),
80
+ )
81
+ const body = http.body
82
+ if (typeof body !== 'object' || body === null || !('messages' in body) || !Array.isArray(body.messages)) {
83
+ throw new Error('unexpected request body')
84
+ }
85
+ const assistant = body.messages.find((m: { role: string }) => m.role === 'assistant')
86
+ if (!assistant || !Array.isArray(assistant.content)) throw new Error('no assistant message with block content')
87
+ return assistant.content
88
+ }
89
+
90
+ describe('Anthropic thinking capture', () => {
91
+ test('captures the blocks whole, signatures included, alongside the plaintext', async () => {
92
+ const provider = createProvider(respondWith([...THINKING_BLOCKS, { type: 'text', text: 'Sunny.' }]))
93
+
94
+ const result = await provider.inference(inferenceRequest())
95
+
96
+ expect(isOk(result)).toBe(true)
97
+ if (!isOk(result)) return
98
+ // Verbatim: the signature is what the API verifies on replay, so nothing may be rebuilt.
99
+ expect(result.value.thinkingBlocks).toEqual(THINKING_BLOCKS)
100
+ expect(result.value.reasoning).toBe('The user is asking about the weather. I should call the tool.')
101
+ expect(result.value.content).toBe('Sunny.')
102
+ })
103
+
104
+ test('leaves both fields unset when thinking is off', async () => {
105
+ const provider = createProvider(respondWith([{ type: 'text', text: 'Sunny.' }]))
106
+
107
+ const result = await provider.inference(inferenceRequest())
108
+
109
+ if (!isOk(result)) throw new Error(`inference failed: ${result.error.type} — ${result.error.message}`)
110
+ expect(result.value.thinkingBlocks).toBeUndefined()
111
+ expect(result.value.reasoning).toBeUndefined()
112
+ })
113
+
114
+ // redacted_thinking has no readable text, but the API still wants it back in sequence.
115
+ test('keeps a redacted block even though it contributes no plaintext', async () => {
116
+ const redacted = [{ type: 'redacted_thinking', data: 'EmwKAhgBEgy3opaque' }]
117
+ const provider = createProvider(respondWith([...redacted, { type: 'text', text: 'Done.' }]))
118
+
119
+ const result = await provider.inference(inferenceRequest())
120
+
121
+ expect(isOk(result)).toBe(true)
122
+ if (!isOk(result)) return
123
+ expect(result.value.thinkingBlocks).toEqual(redacted)
124
+ expect(result.value.reasoning).toBeUndefined()
125
+ })
126
+ })
127
+
128
+ describe('Anthropic thinking echo', () => {
129
+ test('replays the blocks first, ahead of text and tool calls', async () => {
130
+ const blocks = await assistantBlocks(
131
+ {
132
+ role: 'assistant',
133
+ content: 'Let me check.',
134
+ thinkingBlocks: THINKING_BLOCKS,
135
+ toolCalls: [{ id: ToolCallId('toolu_1'), name: 'get_weather', input: { city: 'Prague' } }],
136
+ },
137
+ { role: 'tool', toolCallId: ToolCallId('toolu_1'), content: 'Sunny, 22°C' },
138
+ )
139
+
140
+ // Order is the API's requirement, not a preference.
141
+ expect(blocks).toEqual([
142
+ ...THINKING_BLOCKS,
143
+ { type: 'text', text: 'Let me check.' },
144
+ { type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'Prague' } },
145
+ ])
146
+ })
147
+
148
+ test('replays them on an assistant message that made no tool call', async () => {
149
+ const blocks = await assistantBlocks({ role: 'assistant', content: 'Sunny.', thinkingBlocks: THINKING_BLOCKS })
150
+
151
+ expect(blocks).toEqual([...THINKING_BLOCKS, { type: 'text', text: 'Sunny.' }])
152
+ })
153
+
154
+ // All or nothing: the API rejects an edited or partial sequence, so a set that cannot be
155
+ // recognised in full is dropped in full rather than sent truncated. Reachable if a session
156
+ // switches provider mid-conversation and foreign blocks reach this field.
157
+ test('drops the whole set when any block is unrecognisable', async () => {
158
+ const blocks = await assistantBlocks({
159
+ role: 'assistant',
160
+ content: 'Sunny.',
161
+ thinkingBlocks: [THINKING_BLOCKS[0], { type: 'reasoning.summary', summary: 'from another provider' }],
162
+ })
163
+
164
+ expect(blocks).toEqual([{ type: 'text', text: 'Sunny.' }])
165
+ })
166
+
167
+ test('drops a thinking block whose signature is missing', async () => {
168
+ const blocks = await assistantBlocks({
169
+ role: 'assistant',
170
+ content: 'Sunny.',
171
+ thinkingBlocks: [{ type: 'thinking', thinking: 'no signature here' }],
172
+ })
173
+
174
+ expect(blocks).toEqual([{ type: 'text', text: 'Sunny.' }])
175
+ })
176
+ })
177
+
178
+ describe('Anthropic request bytes without thinking', () => {
179
+ // The prompt cache is keyed on the serialized prefix, so a message with no thinking has to
180
+ // serialize exactly as it did before this feature existed.
181
+ test('adds nothing to a plain assistant message', async () => {
182
+ const http = await createProvider().buildHttpRequest(rawRequest([
183
+ { role: 'user', content: 'What is 2+2?' },
184
+ { role: 'assistant', content: '4' },
185
+ { role: 'user', content: 'And 3+3?' },
186
+ ]))
187
+
188
+ expect(JSON.stringify(http.body)).not.toContain('thinking')
189
+ })
190
+
191
+ test('adds nothing to an assistant message with tool calls', async () => {
192
+ const http = await createProvider().buildHttpRequest(rawRequest([
193
+ { role: 'user', content: 'Weather?' },
194
+ {
195
+ role: 'assistant',
196
+ content: '',
197
+ toolCalls: [{ id: ToolCallId('toolu_1'), name: 'get_weather', input: { city: 'Prague' } }],
198
+ },
199
+ { role: 'tool', toolCallId: ToolCallId('toolu_1'), content: 'Sunny, 22°C' },
200
+ ]))
201
+
202
+ expect(JSON.stringify(http.body)).not.toContain('thinking')
203
+ })
204
+ })
205
+
206
+ describe('Anthropic cache breakpoints around thinking', () => {
207
+ // cache_control on a replayed thinking block is an extra key on a signed payload — rejected.
208
+ // The breakpoint moves to the last block that can hold one.
209
+ test('puts the breakpoint on the text block, not the thinking block', async () => {
210
+ const blocks = await assistantBlocks({
211
+ role: 'assistant',
212
+ content: 'Sunny.',
213
+ thinkingBlocks: THINKING_BLOCKS,
214
+ cacheControl: { type: 'ephemeral' },
215
+ })
216
+
217
+ expect(blocks).toEqual([
218
+ ...THINKING_BLOCKS,
219
+ { type: 'text', text: 'Sunny.', cache_control: { type: 'ephemeral' } },
220
+ ])
221
+ })
222
+
223
+ // Nothing in the message can carry it. Losing a breakpoint is a cache miss; sending an
224
+ // invalid one is a failed request.
225
+ test('places no breakpoint when the message is nothing but thinking', async () => {
226
+ const blocks = await assistantBlocks({
227
+ role: 'assistant',
228
+ content: '',
229
+ thinkingBlocks: THINKING_BLOCKS,
230
+ cacheControl: { type: 'ephemeral' },
231
+ })
232
+
233
+ expect(blocks).toEqual(THINKING_BLOCKS)
234
+ })
235
+ })
236
+
237
+ // ============================================================================
238
+ // Live verification
239
+ // ============================================================================
240
+ //
241
+ // Everything above runs against a mocked fetch, which proves the shape the SDK builds
242
+ // and nothing about whether Anthropic accepts it. This drives the real API through a
243
+ // full tool-use cycle with extended thinking on — the exact flow that returns a 400
244
+ // when the thinking blocks are missing from the assistant turn.
245
+ //
246
+ // LIVE_TESTS=1 ANTHROPIC_API_KEY=… bun test thinking-blocks
247
+
248
+ const liveEnabled = process.env.LIVE_TESTS === '1'
249
+ const liveApiKey = liveEnabled ? process.env.ANTHROPIC_API_KEY : undefined
250
+
251
+ const describeLive = (name: string, fn: () => void) => {
252
+ if (!liveApiKey) {
253
+ describe.skip(`${name} (skipped — LIVE_TESTS=1 + ANTHROPIC_API_KEY required)`, fn)
254
+ return
255
+ }
256
+ describe(name, fn)
257
+ }
258
+
259
+ /** Records every outbound body so the echo can be proven on the wire, not inferred. */
260
+ const createRecordingFetch = (sink: unknown[]): FetchFn => async (input, init) => {
261
+ sink.push(JSON.parse(typeof init?.body === 'string' ? init.body : '{}'))
262
+ return globalThis.fetch(input, init)
263
+ }
264
+
265
+ const liveModel = ModelId('claude-sonnet-4-6')
266
+
267
+ const weatherTool = {
268
+ name: 'get_weather',
269
+ description: 'Get the current weather for a city.',
270
+ parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
271
+ }
272
+
273
+ describeLive('Anthropic extended thinking, live', () => {
274
+ test('a tool-use turn survives the round trip with thinking enabled', async () => {
275
+ const sent: unknown[] = []
276
+ const provider = new AnthropicProvider({
277
+ apiKey: liveApiKey ?? '',
278
+ imageProcessor: { resolveContent: async (content) => content },
279
+ defaultModel: String(liveModel),
280
+ // Anthropic's floor. Enough to make the model actually think.
281
+ thinkingBudget: 1024,
282
+ fetch: createRecordingFetch(sent),
283
+ })
284
+
285
+ const first = await provider.inference({
286
+ model: liveModel,
287
+ systemPrompt: 'You answer weather questions. Always use the tool; never guess.',
288
+ messages: [{ role: 'user', content: 'What is the weather in Prague right now?' }],
289
+ maxTokens: 2048,
290
+ tools: [],
291
+ anthropic: { thinkingBudget: 1024 },
292
+ }, undefined)
293
+
294
+ if (!isOk(first)) throw new Error(`first inference failed: ${first.error.type} — ${first.error.message}`)
295
+ // Thinking has to be on for this test to mean anything.
296
+ expect(first.value.thinkingBlocks?.length).toBeGreaterThan(0)
297
+
298
+ // Replay the captured blocks on the assistant turn, exactly as the agent loop would.
299
+ const second = await provider.inference({
300
+ model: liveModel,
301
+ systemPrompt: 'You answer weather questions. Always use the tool; never guess.',
302
+ messages: [
303
+ { role: 'user', content: 'What is the weather in Prague right now?' },
304
+ {
305
+ role: 'assistant',
306
+ content: first.value.content ?? '',
307
+ thinkingBlocks: first.value.thinkingBlocks,
308
+ },
309
+ { role: 'user', content: 'Thanks. Reply with the single word OK.' },
310
+ ],
311
+ maxTokens: 2048,
312
+ anthropic: { thinkingBudget: 1024 },
313
+ }, undefined)
314
+
315
+ // The assertion that matters: the API accepted the replayed, signed blocks.
316
+ if (!isOk(second)) throw new Error(`replay rejected: ${second.error.type} — ${second.error.message}`)
317
+
318
+ // Proven on the wire: the blocks in the second outbound body are byte-identical to
319
+ // the ones the API returned on the first call.
320
+ const secondBody = JSON.stringify(sent[1])
321
+ expect(secondBody).toContain(JSON.stringify(first.value.thinkingBlocks?.[0]))
322
+ console.log(`LIVE OK ${JSON.stringify({ blocks: first.value.thinkingBlocks?.length, reply: second.value.content })}`)
323
+ }, 120_000)
324
+
325
+ test('no thinking key on the wire when thinking is off', async () => {
326
+ const sent: unknown[] = []
327
+ const provider = new AnthropicProvider({
328
+ apiKey: liveApiKey ?? '',
329
+ imageProcessor: { resolveContent: async (content) => content },
330
+ defaultModel: String(liveModel),
331
+ fetch: createRecordingFetch(sent),
332
+ })
333
+
334
+ const result = await provider.inference({
335
+ model: liveModel,
336
+ systemPrompt: 'Answer in one word.',
337
+ messages: [{ role: 'user', content: 'What is 2+2?' }],
338
+ maxTokens: 64,
339
+ }, undefined)
340
+
341
+ if (!isOk(result)) throw new Error(`inference failed: ${result.error.type} — ${result.error.message}`)
342
+ expect(result.value.thinkingBlocks).toBeUndefined()
343
+ expect(JSON.stringify(sent[0])).not.toContain('thinking')
344
+ }, 120_000)
345
+ })
@@ -9,3 +9,6 @@ export { createPreset, validatePreset } from './config.js'
9
9
  // Re-export from preset-builder
10
10
  export type { CreateOrchestratorInput, DefineAgentInput } from './preset-builder.js'
11
11
  export { createOrchestrator, defineAgent } from './preset-builder.js'
12
+
13
+ // Re-export from overrides
14
+ export { knownDefinitionNames, unknownOverrideTargets } from './overrides.js'
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Validation of session overrides against the preset they target.
3
+ *
4
+ * Overrides are keyed by agent definition name, which makes a typo silent: the
5
+ * entry simply never matches and the agent quietly keeps the preset's model.
6
+ * Every entry point that accepts an override patch rejects unknown names instead.
7
+ */
8
+
9
+ import { COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE } from '~/core/agents/agent-roles.js'
10
+ import type { SessionOverridesPatch } from '~/core/sessions/state.js'
11
+ import type { PresetDefinition } from './config.js'
12
+
13
+ /** Every definition name an override may target, in preset order. */
14
+ export const knownDefinitionNames = (preset: PresetDefinition): string[] => [
15
+ ORCHESTRATOR_ROLE,
16
+ ...(preset.communicator ? [COMMUNICATOR_ROLE] : []),
17
+ ...preset.agents.map(agent => agent.name),
18
+ ]
19
+
20
+ /** Names in the patch that the preset does not define. Empty when the patch is valid. */
21
+ export const unknownOverrideTargets = (preset: PresetDefinition, patch: SessionOverridesPatch): string[] => {
22
+ const known = new Set(knownDefinitionNames(preset))
23
+ return Object.keys(patch.agents ?? {}).filter(name => !known.has(name))
24
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import type { AssistantLLMMessage } from '~/core/agents/state.js'
3
+ import { MemoryEventStore } from '~/core/events/memory.js'
4
+ import { MockLLMProvider } from '~/core/llm/mock.js'
5
+ import type { InferenceRequest, InferenceResponse } from '~/core/llm/provider.js'
6
+ import { llmEvents } from '~/core/llm/state.js'
7
+ import { ToolCallId } from '~/core/tools/schema.js'
8
+ import { createTestPreset, TestHarness } from '~/testing/index.js'
9
+
10
+ // Reasoning blocks have to survive the whole conversation path — the event log,
11
+ // a replay of it, and the message list handed to the next inference — or a
12
+ // reasoning model restarts its chain of thought at every tool call.
13
+
14
+ const REASONING_DETAILS = [
15
+ { type: 'reasoning.encrypted', data: 'EroBCkYIBRgCKkCr0hZ0opaque', id: 'rd-1', format: 'openai-responses-v1', index: 0 },
16
+ ]
17
+
18
+ /** The Anthropic half of the same contract — a separate field, see ThinkingBlocks. */
19
+ const THINKING_BLOCKS = [
20
+ { type: 'thinking', thinking: 'Working it out.', signature: 'EqQBCgIYAhIM1gbcDa9GJwZopaque' },
21
+ ]
22
+
23
+ const assistantMessages = (request: InferenceRequest): AssistantLLMMessage[] =>
24
+ request.messages.filter((m): m is AssistantLLMMessage => m.role === 'assistant')
25
+
26
+ /** Same shape as reasoningThenPlainMock, on the Anthropic field. */
27
+ const thinkingThenPlainMock = (): MockLLMProvider => {
28
+ let call = 0
29
+ return new MockLLMProvider((): InferenceResponse => {
30
+ call++
31
+ if (call === 1) {
32
+ return {
33
+ content: null,
34
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'tell_user', input: { message: 'Hi!' } }],
35
+ finishReason: 'tool_calls',
36
+ metrics: MockLLMProvider.defaultMetrics(),
37
+ thinkingBlocks: THINKING_BLOCKS,
38
+ }
39
+ }
40
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
41
+ })
42
+ }
43
+
44
+ /** Turn 1 reasons its way to a tool call; every later turn just finishes, without reasoning. */
45
+ const reasoningThenPlainMock = (): MockLLMProvider => {
46
+ let call = 0
47
+ return new MockLLMProvider((): InferenceResponse => {
48
+ call++
49
+ if (call === 1) {
50
+ return {
51
+ content: null,
52
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'tell_user', input: { message: 'Hi!' } }],
53
+ finishReason: 'tool_calls',
54
+ metrics: MockLLMProvider.defaultMetrics(),
55
+ reasoningDetails: REASONING_DETAILS,
56
+ }
57
+ }
58
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
59
+ })
60
+ }
61
+
62
+ describe('reasoning round trip', () => {
63
+ it('echoes the blocks back on the follow-up request of the same turn', async () => {
64
+ const llmProvider = reasoningThenPlainMock()
65
+ const harness = new TestHarness({ presets: [createTestPreset()], llmProvider })
66
+
67
+ const session = await harness.createSession('test')
68
+ await session.sendAndWaitForIdle('Do something')
69
+
70
+ const [, second] = llmProvider.getCallHistory()
71
+ const assistants = assistantMessages(second)
72
+ expect(assistants).toHaveLength(1)
73
+ expect(assistants[0].reasoningDetails).toEqual(REASONING_DETAILS)
74
+
75
+ await harness.shutdown()
76
+ })
77
+
78
+ it('stores the blocks in the inference_completed event, serialized verbatim', async () => {
79
+ const llmProvider = reasoningThenPlainMock()
80
+ const harness = new TestHarness({ presets: [createTestPreset()], llmProvider })
81
+
82
+ const session = await harness.createSession('test')
83
+ await session.sendAndWaitForIdle('Do something')
84
+
85
+ const completed = await session.getEventsByType(llmEvents, 'inference_completed')
86
+ expect(completed[0].response.reasoningDetails).toEqual(REASONING_DETAILS)
87
+ // The event is what a restarted server replays, so it has to survive JSON as-is.
88
+ expect(JSON.stringify(completed[0])).toContain(`"reasoningDetails":${JSON.stringify(REASONING_DETAILS)}`)
89
+ expect(completed[1].response.reasoningDetails).toBeUndefined()
90
+
91
+ await harness.shutdown()
92
+ })
93
+
94
+ it('keeps the blocks after the session is reloaded from the event store', async () => {
95
+ const eventStore = new MemoryEventStore()
96
+ const llmProvider = reasoningThenPlainMock()
97
+
98
+ const harness1 = new TestHarness({ eventStore, presets: [createTestPreset()], llmProvider })
99
+ const session1 = await harness1.createSession('test')
100
+ await session1.sendAndWaitForIdle('Do something')
101
+ const sessionId = session1.sessionId
102
+ await harness1.shutdown()
103
+
104
+ // Fresh harness over the same log: conversation history comes from replay only.
105
+ const harness2 = new TestHarness({ eventStore, presets: [createTestPreset()], llmProvider })
106
+ const session2 = await harness2.openSession(sessionId)
107
+ await session2.sendAndWaitForIdle('And again')
108
+
109
+ const assistants = assistantMessages(llmProvider.getCallHistory()[2])
110
+ expect(assistants[0].reasoningDetails).toEqual(REASONING_DETAILS)
111
+
112
+ await harness2.shutdown()
113
+ })
114
+
115
+ it('leaves the assistant message untouched when the model returns no reasoning', async () => {
116
+ const llmProvider = MockLLMProvider.withSequence([
117
+ { toolCalls: [{ id: ToolCallId('tc1'), name: 'tell_user', input: { message: 'Hi!' } }] },
118
+ { content: 'Done', toolCalls: [] },
119
+ ])
120
+ const harness = new TestHarness({ presets: [createTestPreset()], llmProvider })
121
+
122
+ const session = await harness.createSession('test')
123
+ await session.sendAndWaitForIdle('Do something')
124
+
125
+ const assistants = assistantMessages(llmProvider.getCallHistory()[1])
126
+ expect(assistants).toHaveLength(1)
127
+ expect(assistants[0].reasoningDetails).toBeUndefined()
128
+
129
+ await harness.shutdown()
130
+ })
131
+ })
132
+
133
+ // Anthropic needs this harder than OpenRouter does: with extended thinking on, the API
134
+ // rejects a tool-use turn whose thinking blocks are missing, rather than quietly
135
+ // degrading. The two fields stay separate so a mid-conversation model switch cannot put
136
+ // one provider's blocks in front of the other.
137
+ describe('thinking round trip', () => {
138
+ it('echoes the blocks back on the follow-up request of the same turn', async () => {
139
+ const llmProvider = thinkingThenPlainMock()
140
+ const harness = new TestHarness({ presets: [createTestPreset()], llmProvider })
141
+
142
+ const session = await harness.createSession('test')
143
+ await session.sendAndWaitForIdle('Do something')
144
+
145
+ const [, second] = llmProvider.getCallHistory()
146
+ const assistants = assistantMessages(second)
147
+ expect(assistants).toHaveLength(1)
148
+ expect(assistants[0].thinkingBlocks).toEqual(THINKING_BLOCKS)
149
+ // The other field stays empty: nothing cross-populates.
150
+ expect(assistants[0].reasoningDetails).toBeUndefined()
151
+
152
+ await harness.shutdown()
153
+ })
154
+
155
+ it('keeps the blocks after the session is reloaded from the event store', async () => {
156
+ const eventStore = new MemoryEventStore()
157
+ const llmProvider = thinkingThenPlainMock()
158
+
159
+ const harness1 = new TestHarness({ eventStore, presets: [createTestPreset()], llmProvider })
160
+ const session1 = await harness1.createSession('test')
161
+ await session1.sendAndWaitForIdle('Do something')
162
+ const sessionId = session1.sessionId
163
+ await harness1.shutdown()
164
+
165
+ const harness2 = new TestHarness({ eventStore, presets: [createTestPreset()], llmProvider })
166
+ const session2 = await harness2.openSession(sessionId)
167
+ await session2.sendAndWaitForIdle('And again')
168
+
169
+ const assistants = assistantMessages(llmProvider.getCallHistory()[2])
170
+ expect(assistants[0].thinkingBlocks).toEqual(THINKING_BLOCKS)
171
+
172
+ await harness2.shutdown()
173
+ })
174
+
175
+ it('stores the blocks in the inference_completed event, serialized verbatim', async () => {
176
+ const llmProvider = thinkingThenPlainMock()
177
+ const harness = new TestHarness({ presets: [createTestPreset()], llmProvider })
178
+
179
+ const session = await harness.createSession('test')
180
+ await session.sendAndWaitForIdle('Do something')
181
+
182
+ const completed = await session.getEventsByType(llmEvents, 'inference_completed')
183
+ expect(JSON.stringify(completed[0])).toContain(`"thinkingBlocks":${JSON.stringify(THINKING_BLOCKS)}`)
184
+ expect(completed[1].response.thinkingBlocks).toBeUndefined()
185
+
186
+ await harness.shutdown()
187
+ })
188
+ })
@@ -21,9 +21,10 @@ import type { LLMLogger } from '~/core/llm/logger.js'
21
21
  import type { LLMProvider } from '~/core/llm/provider.js'
22
22
  import type { CallerContext, ConfiguredPlugin, ManagerMethodContext, PluginDefinition } from '~/core/plugins/plugin-builder.js'
23
23
  import type { Preset } from '~/core/preset/index.js'
24
+ import { knownDefinitionNames, unknownOverrideTargets } from '~/core/preset/overrides.js'
24
25
  import type { SessionId } from '~/core/sessions/schema.js'
25
26
  import { generateSessionId } from '~/core/sessions/schema.js'
26
- import type { SessionCreatedEvent } from '~/core/sessions/state.js'
27
+ import type { SessionCreatedEvent, SessionOverridesPatch } from '~/core/sessions/state.js'
27
28
  import { checkRecoveryNeeded, isSessionCreatedEvent, reconstructSessionState, sessionEvents } from '~/core/sessions/state.js'
28
29
  import type { ToolExecutor } from '~/core/tools'
29
30
  import { FileLogger } from '~/lib/logger/file.js'
@@ -143,13 +144,22 @@ export class SessionManager {
143
144
  */
144
145
  async createSession(
145
146
  presetId: string,
146
- options?: { workspaceDir?: string; sessionId?: string },
147
+ options?: { workspaceDir?: string; sessionId?: string; overrides?: SessionOverridesPatch },
147
148
  ): Promise<Result<Session, DomainError>> {
148
149
  const preset = this.presets.get(presetId)
149
150
  if (!preset) {
150
151
  return Err(PresetErrors.notFound(presetId))
151
152
  }
152
153
 
154
+ if (options?.overrides) {
155
+ const unknown = unknownOverrideTargets(preset, options.overrides)
156
+ if (unknown.length > 0) {
157
+ return Err(ValidationErrors.invalid(
158
+ `Unknown agent definitions in overrides: ${unknown.join(', ')}. Preset '${presetId}' defines: ${knownDefinitionNames(preset).join(', ')}`,
159
+ ))
160
+ }
161
+ }
162
+
153
163
  const sessionId = options?.sessionId ? (options.sessionId as any) : generateSessionId()
154
164
  // First orchestrator gets seq 1
155
165
  const orchestratorId = generateAgentId(ORCHESTRATOR_ROLE, 1)
@@ -175,6 +185,15 @@ export class SessionManager {
175
185
  ...(workspaceDir ? { workspaceDir } : {}),
176
186
  }),
177
187
  ),
188
+ ]
189
+
190
+ // Seeded through the same event a runtime change uses, so there is one code
191
+ // path and a forked/replayed session reconstructs the overrides for free.
192
+ if (options?.overrides) {
193
+ events.push(withSessionId(sessionId, sessionEvents.create('session_overrides_set', options.overrides)))
194
+ }
195
+
196
+ events.push(
178
197
  withSessionId(
179
198
  sessionId,
180
199
  agentEvents.create('agent_spawned', {
@@ -183,7 +202,7 @@ export class SessionManager {
183
202
  parentId: null,
184
203
  }),
185
204
  ),
186
- ]
205
+ )
187
206
 
188
207
  // Spawn communicator if configured
189
208
  const hasCommunicator = !!preset.communicator