@roj-ai/sdk 0.1.30 → 0.1.32

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 (45) hide show
  1. package/dist/core/agents/agent.d.ts +5 -1
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js.map +1 -1
  4. package/dist/core/agents/config.d.ts +5 -1
  5. package/dist/core/agents/config.d.ts.map +1 -1
  6. package/dist/core/llm/anthropic.d.ts.map +1 -1
  7. package/dist/core/llm/anthropic.js +7 -1
  8. package/dist/core/llm/anthropic.js.map +1 -1
  9. package/dist/core/llm/cache-breakpoints.d.ts +39 -5
  10. package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
  11. package/dist/core/llm/cache-breakpoints.js +22 -8
  12. package/dist/core/llm/cache-breakpoints.js.map +1 -1
  13. package/dist/core/llm/cache-breakpoints.test.js +32 -8
  14. package/dist/core/llm/cache-breakpoints.test.js.map +1 -1
  15. package/dist/core/llm/openrouter-mapping.test.js +29 -0
  16. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  17. package/dist/core/llm/openrouter.d.ts.map +1 -1
  18. package/dist/core/llm/openrouter.js +28 -8
  19. package/dist/core/llm/openrouter.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugins/agents/agents.integration.test.js +147 -0
  24. package/dist/plugins/agents/agents.integration.test.js.map +1 -1
  25. package/dist/plugins/agents/plugin.d.ts.map +1 -1
  26. package/dist/plugins/agents/plugin.js +35 -2
  27. package/dist/plugins/agents/plugin.js.map +1 -1
  28. package/dist/plugins/result-eviction/plugin.d.ts.map +1 -1
  29. package/dist/plugins/result-eviction/plugin.js +24 -9
  30. package/dist/plugins/result-eviction/plugin.js.map +1 -1
  31. package/dist/plugins/result-eviction/result-eviction.integration.test.js +77 -0
  32. package/dist/plugins/result-eviction/result-eviction.integration.test.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/core/agents/agent.ts +5 -2
  35. package/src/core/agents/config.ts +5 -1
  36. package/src/core/llm/anthropic.ts +7 -1
  37. package/src/core/llm/cache-breakpoints.test.ts +38 -8
  38. package/src/core/llm/cache-breakpoints.ts +54 -9
  39. package/src/core/llm/openrouter-mapping.test.ts +37 -1
  40. package/src/core/llm/openrouter.ts +35 -9
  41. package/src/index.ts +1 -0
  42. package/src/plugins/agents/agents.integration.test.ts +168 -0
  43. package/src/plugins/agents/plugin.ts +39 -2
  44. package/src/plugins/result-eviction/plugin.ts +26 -11
  45. package/src/plugins/result-eviction/result-eviction.integration.test.ts +88 -0
@@ -89,11 +89,17 @@ interface OpenRouterErrorResponse {
89
89
  // Request body types
90
90
  // ============================================================================
91
91
 
92
+ interface OpenRouterCacheControl {
93
+ type: 'ephemeral'
94
+ /** Omitted = the provider default 5-minute tier. */
95
+ ttl?: '5m' | '1h'
96
+ }
97
+
92
98
  interface OpenRouterContentItem {
93
99
  type: string
94
100
  text?: string
95
101
  image_url?: { url: string; detail?: string }
96
- cache_control?: { type: 'ephemeral' }
102
+ cache_control?: OpenRouterCacheControl
97
103
  }
98
104
 
99
105
  interface OpenRouterMessage {
@@ -104,18 +110,34 @@ interface OpenRouterMessage {
104
110
  }
105
111
 
106
112
  /**
107
- * Add `cache_control: { type: 'ephemeral' }` to the LAST content block of an
108
- * OpenRouterMessage, converting string content to an array text block first
109
- * so the mark has a place to live.
113
+ * Add `cache_control` to the LAST content block of an OpenRouterMessage,
114
+ * converting string content to an array text block first so the mark has a
115
+ * place to live.
116
+ *
117
+ * `ttl` reaches Anthropic through the Chat Completions surface. It is NOT
118
+ * expressible on the Responses API, where a breakpoint is downgraded to the
119
+ * default 5-minute tier — so this must stay on `/chat/completions`.
110
120
  */
111
- function applyCacheControlToLastBlock(msg: OpenRouterMessage): void {
121
+ function applyCacheControlToLastBlock(msg: OpenRouterMessage, cacheControl: OpenRouterCacheControl): void {
112
122
  if (typeof msg.content === 'string') {
113
- msg.content = [{ type: 'text', text: msg.content, cache_control: { type: 'ephemeral' } }]
123
+ msg.content = [{ type: 'text', text: msg.content, cache_control: cacheControl }]
114
124
  return
115
125
  }
116
126
  if (msg.content.length === 0) return
117
127
  const lastIdx = msg.content.length - 1
118
- msg.content[lastIdx] = { ...msg.content[lastIdx], cache_control: { type: 'ephemeral' } }
128
+ msg.content[lastIdx] = { ...msg.content[lastIdx], cache_control: cacheControl }
129
+ }
130
+
131
+ /**
132
+ * Longest TTL requested anywhere in the message list, or undefined when none.
133
+ *
134
+ * The system prompt precedes every message, and Anthropic requires a longer TTL
135
+ * to come BEFORE a shorter one. So when any breakpoint asks for 1h, the system
136
+ * block has to carry 1h too — otherwise the order is 5m → 1h → 5m and the
137
+ * longer entry is invalid.
138
+ */
139
+ function longestCacheTtl(messages: LLMMessage[]): '1h' | undefined {
140
+ return messages.some((m) => m.cacheControl?.ttl === '1h') ? '1h' : undefined
119
141
  }
120
142
 
121
143
  interface OpenRouterToolDefinition {
@@ -261,8 +283,10 @@ export class OpenRouterProvider implements LLMProvider {
261
283
  const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
262
284
  const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
263
285
 
286
+ const systemTtl = longestCacheTtl(sanitizedMessages)
287
+ const systemCacheControl: OpenRouterCacheControl = systemTtl ? { type: 'ephemeral', ttl: systemTtl } : { type: 'ephemeral' }
264
288
  const messages: OpenRouterMessage[] = [
265
- { role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }] },
289
+ { role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control: systemCacheControl }] },
266
290
  ...mappedMessages,
267
291
  ]
268
292
 
@@ -317,7 +341,9 @@ export class OpenRouterProvider implements LLMProvider {
317
341
  private async mapMessage(msg: LLMMessage, context?: InferenceContext): Promise<OpenRouterMessage> {
318
342
  const mapped = await this.mapMessageContent(msg, context)
319
343
  if (msg.cacheControl) {
320
- applyCacheControlToLastBlock(mapped)
344
+ const cacheControl: OpenRouterCacheControl = { type: 'ephemeral' }
345
+ if (msg.cacheControl.ttl) cacheControl.ttl = msg.cacheControl.ttl
346
+ applyCacheControlToLastBlock(mapped, cacheControl)
321
347
  }
322
348
  return mapped
323
349
  }
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export type { AgentPauseReason, AgentState, LLMMessage } from '~/core/agents/sta
53
53
  export type { DomainEvent } from '~/core/events/types.js'
54
54
  export type { FactoryEventType } from '~/core/events/types.js'
55
55
  export { isDomainEvent } from '~/core/events/types.js'
56
+ export type { CacheTtl, CacheTtlConfig } from '~/core/llm/cache-breakpoints.js'
56
57
  export type { ChatMessageContentItem, LLMCallLogEntry, LLMCallMessage, ToolResultContent } from '~/core/llm/llm-log-types.js'
57
58
  export { contentToString } from '~/core/llm/llm-log-types.js'
58
59
  export { applyMiddleware, useProvider, withAnthropic, withMaxTokens, withOpenRouter, withTemperature } from '~/core/llm/middleware.js'
@@ -805,4 +805,172 @@ describe('agents plugin', () => {
805
805
  await harness.shutdown()
806
806
  })
807
807
  })
808
+
809
+ // =========================================================================
810
+ // resume_agent tool
811
+ // =========================================================================
812
+
813
+ describe('resume_agent tool', () => {
814
+ // A paused child never becomes idle, so waitForAllAgentsIdle can't be used here —
815
+ // wait for the effect instead.
816
+ const waitFor = async (predicate: () => boolean, timeoutMs = 5000): Promise<void> => {
817
+ const deadline = Date.now() + timeoutMs
818
+ while (Date.now() < deadline) {
819
+ if (predicate()) return
820
+ await new Promise(resolve => setTimeout(resolve, 10))
821
+ }
822
+ throw new Error('waitFor timed out')
823
+ }
824
+
825
+ const workerPreset = () => createMultiAgentPreset([
826
+ { name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
827
+ ], { orchestratorSystem: 'Orchestrator agent.' })
828
+
829
+ it('is offered to an agent that can spawn, and not to a leaf', async () => {
830
+ const toolNames = new Map<string, string[]>()
831
+
832
+ const harness = new TestHarness({
833
+ presets: [workerPreset()],
834
+ mockHandler: (request) => {
835
+ const role = request.systemPrompt.includes('Orchestrator') ? 'orchestrator' : 'worker'
836
+ toolNames.set(role, (request.tools ?? []).map(tool => tool.name))
837
+ if (role === 'orchestrator' && !toolNames.has('worker')) {
838
+ return {
839
+ content: null,
840
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Work' } }],
841
+ finishReason: 'stop',
842
+ metrics: MockLLMProvider.defaultMetrics(),
843
+ }
844
+ }
845
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
846
+ },
847
+ })
848
+
849
+ const session = await harness.createSession('test')
850
+ await session.sendAndWaitForIdle('Start')
851
+
852
+ expect(toolNames.get('orchestrator')).toContain('resume_agent')
853
+ expect(toolNames.get('worker')).not.toContain('resume_agent')
854
+
855
+ await harness.shutdown()
856
+ })
857
+
858
+ it('orchestrator resumes its paused child', async () => {
859
+ let started = false
860
+ let resumeIssued = false
861
+
862
+ const harness = new TestHarness({
863
+ presets: [workerPreset()],
864
+ mockHandler: (request) => {
865
+ if (request.systemPrompt.includes('Orchestrator')) {
866
+ const asked = JSON.stringify(request.messages).includes('Resume the worker')
867
+ if (asked && !resumeIssued) {
868
+ resumeIssued = true
869
+ return {
870
+ content: null,
871
+ toolCalls: [{ id: ToolCallId('tc2'), name: 'resume_agent', input: { agentId: 'worker_1' } }],
872
+ finishReason: 'stop',
873
+ metrics: MockLLMProvider.defaultMetrics(),
874
+ }
875
+ }
876
+ if (!started) {
877
+ started = true
878
+ return {
879
+ content: null,
880
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Work' } }],
881
+ finishReason: 'stop',
882
+ metrics: MockLLMProvider.defaultMetrics(),
883
+ }
884
+ }
885
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
886
+ }
887
+ return { content: 'Worker done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
888
+ },
889
+ })
890
+
891
+ const session = await harness.createSession('test')
892
+ await session.sendAndWaitForIdle('Start')
893
+
894
+ const pauseResult = await session.callPluginMethod('agents.pause', { agentId: 'worker_1' })
895
+ expect(pauseResult.ok).toBe(true)
896
+ expect(session.state.agents.get(AgentId('worker_1'))!.status).toBe('paused')
897
+
898
+ await session.sendMessage('Resume the worker')
899
+ await waitFor(() => session.state.agents.get(AgentId('worker_1'))!.status !== 'paused')
900
+
901
+ const resumedEvents = await session.getEventsByType(agentEvents, 'agent_resumed')
902
+ expect(resumedEvents.filter(e => e.agentId === AgentId('worker_1'))).toHaveLength(1)
903
+ expect(session.state.agents.get(AgentId('worker_1'))!.status).not.toBe('paused')
904
+
905
+ await harness.shutdown()
906
+ })
907
+
908
+ it('refuses an agent that is not the caller\'s child', async () => {
909
+ let delegated = false
910
+ let subDelegated = false
911
+ let resumeIssued = false
912
+ let toolResult = ''
913
+
914
+ const harness = new TestHarness({
915
+ presets: [createMultiAgentPreset([
916
+ { name: 'worker_a', system: 'Worker A agent.', tools: [], agents: ['worker_b'] },
917
+ { name: 'worker_b', system: 'Worker B agent.', tools: [], agents: [] },
918
+ ], { orchestratorSystem: 'Orchestrator agent.' })],
919
+ mockHandler: (request) => {
920
+ if (request.systemPrompt.includes('Orchestrator')) {
921
+ const last = request.messages[request.messages.length - 1]
922
+ if (last?.role === 'tool' && resumeIssued) toolResult = JSON.stringify(last.content)
923
+
924
+ const asked = JSON.stringify(request.messages).includes('Resume the grandchild')
925
+ if (asked && !resumeIssued) {
926
+ resumeIssued = true
927
+ // worker_b is a grandchild — spawned by worker_a, not by the orchestrator
928
+ return {
929
+ content: null,
930
+ toolCalls: [{ id: ToolCallId('tc3'), name: 'resume_agent', input: { agentId: 'worker_b_1' } }],
931
+ finishReason: 'stop',
932
+ metrics: MockLLMProvider.defaultMetrics(),
933
+ }
934
+ }
935
+ if (!delegated) {
936
+ delegated = true
937
+ return {
938
+ content: null,
939
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker_a', input: { message: 'Delegate' } }],
940
+ finishReason: 'stop',
941
+ metrics: MockLLMProvider.defaultMetrics(),
942
+ }
943
+ }
944
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
945
+ }
946
+
947
+ if (request.systemPrompt.includes('Worker A')) {
948
+ if (subDelegated) {
949
+ return { content: 'Worker A done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
950
+ }
951
+ subDelegated = true
952
+ return {
953
+ content: null,
954
+ toolCalls: [{ id: ToolCallId('tc2'), name: 'start_worker_b', input: { message: 'Sub-task' } }],
955
+ finishReason: 'stop',
956
+ metrics: MockLLMProvider.defaultMetrics(),
957
+ }
958
+ }
959
+ return { content: 'Worker done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
960
+ },
961
+ })
962
+
963
+ const session = await harness.createSession('test')
964
+ await session.sendAndWaitForIdle('Start', { timeoutMs: 15_000 })
965
+
966
+ await session.callPluginMethod('agents.pause', { agentId: 'worker_b_1' })
967
+ await session.sendMessage('Resume the grandchild')
968
+ await waitFor(() => toolResult !== '')
969
+
970
+ expect(toolResult).toContain('not your child')
971
+ expect(session.state.agents.get(AgentId('worker_b_1'))!.status).toBe('paused')
972
+
973
+ await harness.shutdown()
974
+ }, 20_000)
975
+ })
808
976
  })
@@ -420,7 +420,7 @@ export const agentsPlugin = definePlugin('agents')
420
420
  - **New task** → spawn a new agent using \`start_<agent_name>\`. You will receive the agent's ID in the result — use it with \`send_message\` for follow-up communication.
421
421
  - **Follow-up on an existing task** → send a message to the existing agent via \`send_message\` with the agent's ID. Do NOT spawn a new agent for feedback, corrections, or additional instructions on a task already assigned.
422
422
  - Spawned agents communicate back to you via \`send_message\`. Check your incoming messages for their results and progress updates.
423
- - If a child pauses early it sends you a \`<child-paused agent="…">reason</child-paused>\` message (e.g. it hit a cost/limit budget). Decide what to do: resume it (after addressing the cause), reassign or drop the work, or stop.`
423
+ - If a child pauses early it sends you a \`<child-paused agent="…">reason</child-paused>\` message (e.g. it hit a cost/limit budget). Decide what to do: resume it with \`resume_agent\` **after addressing the cause**, reassign or drop the work, or stop. Resuming does not grant more budget — a child that hit a cost or token limit will stop again immediately, so don't retry it in a loop.`
424
424
 
425
425
  // Only include supervision instructions if supervision is actually enabled
426
426
  // for this session — otherwise the section is misleading bloat.
@@ -438,7 +438,41 @@ Do NOT act on a supervision tick unless something is genuinely wrong (a child ha
438
438
  const spawnableAgents = ctx.agentConfig.spawnableAgents
439
439
  const agentDefs = ctx.pluginConfig.agentDefinitions
440
440
 
441
- return spawnableAgents.map((agentName) => {
441
+ // The parent is told (see the `<child-paused>` line in the system prompt above) that it
442
+ // may resume a paused child. Without this tool that instruction had nothing behind it:
443
+ // `resume` existed only as a plugin method, reachable from outside the session via
444
+ // `agents.resume`, so a child that paused stayed paused until something external
445
+ // intervened. Only offered to agents that can actually have children.
446
+ const resumeTool = createTool({
447
+ name: 'resume_agent',
448
+ description:
449
+ 'Resume one of your own child agents after it paused (you receive a <child-paused> message when that happens). '
450
+ + 'Address the cause first — resuming does not grant more budget, so a child that hit a cost or token limit '
451
+ + 'will stop again immediately unless the limit itself is raised.',
452
+ input: z.object({
453
+ agentId: agentIdSchema.describe('ID of the paused child agent, as given in the <child-paused> message'),
454
+ }),
455
+ execute: async (input, context) => {
456
+ const target = context.sessionState.agents.get(input.agentId)
457
+ if (!target) {
458
+ return Err({ message: `No agent "${input.agentId}" in this session.`, recoverable: false })
459
+ }
460
+ if (target.parentId !== context.agentId) {
461
+ return Err({ message: `Agent "${input.agentId}" is not your child — you can only resume agents you spawned.`, recoverable: false })
462
+ }
463
+ if (target.status !== 'paused' && target.status !== 'errored') {
464
+ return Err({ message: `Agent "${input.agentId}" is ${target.status}, not paused — nothing to resume.`, recoverable: false })
465
+ }
466
+
467
+ const result = await ctx.self.resume({ agentId: input.agentId })
468
+ if (!result.ok) {
469
+ return Err({ message: result.error.message, recoverable: false })
470
+ }
471
+ return Ok(`Agent "${input.agentId}" resumed.`)
472
+ },
473
+ })
474
+
475
+ const startTools = spawnableAgents.map((agentName) => {
442
476
  const agentInfo = agentDefs.get(agentName) ?? { name: agentName }
443
477
  const toolName = `start_${agentInfo.name}`
444
478
  const description = agentInfo.description
@@ -469,5 +503,8 @@ Do NOT act on a supervision tick unless something is genuinely wrong (a child ha
469
503
  },
470
504
  })
471
505
  })
506
+
507
+ // An agent with nothing to spawn has no children to resume either.
508
+ return spawnableAgents.length > 0 ? [...startTools, resumeTool] : []
472
509
  })
473
510
  .build()
@@ -6,6 +6,7 @@
6
6
  * head + tail + file path is returned instead.
7
7
  */
8
8
 
9
+ import { type ChatMessageContentItem, contentToString, type ToolResultContent } from '~/core/llm/llm-log-types.js'
9
10
  import { truncateByTokens } from '~/core/llm/tokens.js'
10
11
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
11
12
 
@@ -23,6 +24,21 @@ export interface EvictionAgentConfig {
23
24
 
24
25
  const DEFAULT_MAX_TOKENS = 20_000
25
26
 
27
+ const isImage = (item: ChatMessageContentItem): boolean => item.type === 'image_url'
28
+
29
+ /**
30
+ * Rebuild a multi-part result with its text collapsed to `preview` and every
31
+ * image part preserved in order.
32
+ *
33
+ * Images are kept rather than truncated because there is no partial image — the
34
+ * alternative to keeping it is dropping the observation entirely. Text is the
35
+ * part that both dominates the payload and survives being summarised.
36
+ */
37
+ const withTextReplaced = (content: ChatMessageContentItem[], preview: string): ChatMessageContentItem[] => [
38
+ { type: 'text', text: preview },
39
+ ...content.filter(isImage),
40
+ ]
41
+
26
42
  export const resultEvictionPlugin = definePlugin('result-eviction')
27
43
  .agentConfig<EvictionAgentConfig>()
28
44
  .hook('afterToolCall', async (ctx) => {
@@ -34,31 +50,30 @@ export const resultEvictionPlugin = definePlugin('result-eviction')
34
50
  }
35
51
 
36
52
  const { content } = ctx.result
37
-
38
- // Only evict string content
39
- if (typeof content !== 'string') {
40
- return null
41
- }
42
-
43
53
  const maxTokens = agentConfig?.config?.maxTokens ?? DEFAULT_MAX_TOKENS
44
54
 
45
- const truncation = truncateByTokens(content, maxTokens)
55
+ // Multi-part results (e.g. a page snapshot returning JSON + a screenshot)
56
+ // used to bail out here, so no threshold could ever evict them however
57
+ // large the text grew. Measure the text, evict the text, keep the images.
58
+ const text = contentToString(content)
59
+ const truncation = truncateByTokens(text, maxTokens)
46
60
  if (!truncation) {
47
61
  return null
48
62
  }
49
63
 
50
- // Write full content to file via FileStore
64
+ // Write full text to file via FileStore
51
65
  const fileName = `${ctx.toolCall.id}.txt`
52
66
  const filePath = `.results/${fileName}`
53
- await ctx.files.session.write(filePath, content)
67
+ await ctx.files.session.write(filePath, text)
54
68
 
55
- const truncatedContent = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
69
+ const preview = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
70
+ const evicted: ToolResultContent = typeof content === 'string' ? preview : withTextReplaced(content, preview)
56
71
 
57
72
  return {
58
73
  action: 'modify',
59
74
  result: {
60
75
  isError: false,
61
- content: truncatedContent,
76
+ content: evicted,
62
77
  },
63
78
  }
64
79
  })
@@ -19,6 +19,18 @@ const bigOutputPlugin = definePlugin('big-output')
19
19
  input: z.object({ size: z.number() }),
20
20
  execute: async (input) => Ok('x'.repeat(input.size)),
21
21
  }),
22
+ // Mirrors a page-snapshot style result: a large JSON/text block plus an
23
+ // image part. These used to bypass eviction entirely.
24
+ createTool({
25
+ name: 'generate_snapshot',
26
+ description: 'Generate a text + image result of a given text size',
27
+ input: z.object({ size: z.number() }),
28
+ execute: async (input) =>
29
+ Ok([
30
+ { type: 'text' as const, text: 'x'.repeat(input.size) },
31
+ { type: 'image_url' as const, imageUrl: { url: 'data:image/png;base64,AAAA' } },
32
+ ]),
33
+ }),
22
34
  ])
23
35
  .build()
24
36
 
@@ -100,6 +112,82 @@ describe('result-eviction plugin', () => {
100
112
  await harness.shutdown()
101
113
  })
102
114
 
115
+ it('large multi-part output → text evicted, image part kept', async () => {
116
+ let toolContent: unknown = null
117
+ const harness = createEvictionHarness({
118
+ presets: [createEvictionPreset()],
119
+ mockHandler: (request) => {
120
+ const callCount = harness.llmProvider.getCallCount()
121
+ if (callCount === 1) {
122
+ return {
123
+ content: null,
124
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 250_000 } }],
125
+ finishReason: 'stop',
126
+ metrics: MockLLMProvider.defaultMetrics(),
127
+ }
128
+ }
129
+ for (const msg of request.messages) {
130
+ if (msg.role === 'tool' && Array.isArray(msg.content)) {
131
+ toolContent = msg.content
132
+ }
133
+ }
134
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
135
+ },
136
+ })
137
+
138
+ const session = await harness.createSession('test')
139
+ await session.sendAndWaitForIdle('Generate large snapshot')
140
+
141
+ expect(Array.isArray(toolContent)).toBe(true)
142
+ const parts = toolContent as Array<{ type: string; text?: string }>
143
+
144
+ // Text collapsed to a preview that names the spill file...
145
+ const textParts = parts.filter((p) => p.type === 'text')
146
+ expect(textParts).toHaveLength(1)
147
+ expect(textParts[0].text).toContain('truncated')
148
+ expect(textParts[0].text).toContain('.results/')
149
+ expect(textParts[0].text!.length).toBeLessThan(250_000)
150
+
151
+ // ...and the image survives, because there is no partial image: the only
152
+ // alternative to keeping it is losing the observation.
153
+ expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
154
+
155
+ await harness.shutdown()
156
+ })
157
+
158
+ it('small multi-part output → returned unchanged', async () => {
159
+ let toolContent: unknown = null
160
+ const harness = createEvictionHarness({
161
+ presets: [createEvictionPreset()],
162
+ mockHandler: (request) => {
163
+ const callCount = harness.llmProvider.getCallCount()
164
+ if (callCount === 1) {
165
+ return {
166
+ content: null,
167
+ toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 100 } }],
168
+ finishReason: 'stop',
169
+ metrics: MockLLMProvider.defaultMetrics(),
170
+ }
171
+ }
172
+ for (const msg of request.messages) {
173
+ if (msg.role === 'tool' && Array.isArray(msg.content)) {
174
+ toolContent = msg.content
175
+ }
176
+ }
177
+ return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
178
+ },
179
+ })
180
+
181
+ const session = await harness.createSession('test')
182
+ await session.sendAndWaitForIdle('Generate small snapshot')
183
+
184
+ const parts = toolContent as Array<{ type: string; text?: string }>
185
+ expect(parts.filter((p) => p.type === 'text')[0].text).toBe('x'.repeat(100))
186
+ expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
187
+
188
+ await harness.shutdown()
189
+ })
190
+
103
191
  it('full output saved to .results/<toolCallId>.txt', async () => {
104
192
  const harness = createEvictionHarness({
105
193
  presets: [createEvictionPreset()],