@namzu/sdk 1.1.0 → 1.2.0

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 (140) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +4 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/{runtime/query → constants}/continuation.d.ts +5 -0
  9. package/dist/constants/continuation.d.ts.map +1 -0
  10. package/dist/{runtime/query → constants}/continuation.js +5 -0
  11. package/dist/constants/continuation.js.map +1 -0
  12. package/dist/constants/index.d.ts +1 -0
  13. package/dist/constants/index.d.ts.map +1 -1
  14. package/dist/constants/index.js +1 -0
  15. package/dist/constants/index.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +11 -0
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +30 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/capabilities.d.ts +27 -0
  21. package/dist/provider/capabilities.d.ts.map +1 -0
  22. package/dist/provider/capabilities.js +29 -0
  23. package/dist/provider/capabilities.js.map +1 -0
  24. package/dist/provider/index.d.ts +2 -0
  25. package/dist/provider/index.d.ts.map +1 -1
  26. package/dist/provider/index.js +1 -0
  27. package/dist/provider/index.js.map +1 -1
  28. package/dist/public-runtime.d.ts +3 -2
  29. package/dist/public-runtime.d.ts.map +1 -1
  30. package/dist/public-runtime.js +2 -2
  31. package/dist/public-runtime.js.map +1 -1
  32. package/dist/run/reporter.d.ts.map +1 -1
  33. package/dist/run/reporter.js +8 -0
  34. package/dist/run/reporter.js.map +1 -1
  35. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts +2 -0
  36. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts.map +1 -0
  37. package/dist/runtime/query/__tests__/capability-negotiation.test.js +184 -0
  38. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -0
  39. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts +2 -0
  40. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts.map +1 -0
  41. package/dist/runtime/query/__tests__/checkpoint-store.test.js +259 -0
  42. package/dist/runtime/query/__tests__/checkpoint-store.test.js.map +1 -0
  43. package/dist/runtime/query/__tests__/checkpoint.test.js +9 -3
  44. package/dist/runtime/query/__tests__/checkpoint.test.js.map +1 -1
  45. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts +2 -0
  46. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts.map +1 -0
  47. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js +110 -0
  48. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js.map +1 -0
  49. package/dist/runtime/query/checkpoint.d.ts +16 -2
  50. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  51. package/dist/runtime/query/checkpoint.js +21 -7
  52. package/dist/runtime/query/checkpoint.js.map +1 -1
  53. package/dist/runtime/query/context.d.ts +7 -0
  54. package/dist/runtime/query/context.d.ts.map +1 -1
  55. package/dist/runtime/query/context.js +1 -0
  56. package/dist/runtime/query/context.js.map +1 -1
  57. package/dist/runtime/query/index.d.ts +20 -0
  58. package/dist/runtime/query/index.d.ts.map +1 -1
  59. package/dist/runtime/query/index.js +75 -6
  60. package/dist/runtime/query/index.js.map +1 -1
  61. package/dist/runtime/query/iteration/index.d.ts +3 -57
  62. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/index.js +246 -735
  64. package/dist/runtime/query/iteration/index.js.map +1 -1
  65. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/phases/checkpoint.js +22 -0
  67. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  68. package/dist/runtime/query/iteration/phases/context.d.ts +1 -2
  69. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  70. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  71. package/dist/runtime/query/iteration/stream-turn.d.ts +35 -0
  72. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -0
  73. package/dist/runtime/query/iteration/stream-turn.js +341 -0
  74. package/dist/runtime/query/iteration/stream-turn.js.map +1 -0
  75. package/dist/runtime/query/replay/list.d.ts +17 -1
  76. package/dist/runtime/query/replay/list.d.ts.map +1 -1
  77. package/dist/runtime/query/replay/list.js +21 -4
  78. package/dist/runtime/query/replay/list.js.map +1 -1
  79. package/dist/runtime/query/replay/prepare.d.ts +10 -0
  80. package/dist/runtime/query/replay/prepare.d.ts.map +1 -1
  81. package/dist/runtime/query/replay/prepare.js +20 -4
  82. package/dist/runtime/query/replay/prepare.js.map +1 -1
  83. package/dist/store/index.d.ts +1 -0
  84. package/dist/store/index.d.ts.map +1 -1
  85. package/dist/store/index.js +1 -0
  86. package/dist/store/index.js.map +1 -1
  87. package/dist/store/run/checkpoint-disk.d.ts +28 -0
  88. package/dist/store/run/checkpoint-disk.d.ts.map +1 -0
  89. package/dist/store/run/checkpoint-disk.js +55 -0
  90. package/dist/store/run/checkpoint-disk.js.map +1 -0
  91. package/dist/types/provider/config.d.ts +18 -0
  92. package/dist/types/provider/config.d.ts.map +1 -1
  93. package/dist/types/provider/interface.d.ts +10 -0
  94. package/dist/types/provider/interface.d.ts.map +1 -1
  95. package/dist/types/run/checkpoint-store.d.ts +64 -0
  96. package/dist/types/run/checkpoint-store.d.ts.map +1 -0
  97. package/dist/types/run/checkpoint-store.js +12 -0
  98. package/dist/types/run/checkpoint-store.js.map +1 -0
  99. package/dist/types/run/config.d.ts +27 -0
  100. package/dist/types/run/config.d.ts.map +1 -1
  101. package/dist/types/run/events.d.ts +6 -0
  102. package/dist/types/run/events.d.ts.map +1 -1
  103. package/dist/types/run/events.js.map +1 -1
  104. package/dist/types/run/index.d.ts +1 -0
  105. package/dist/types/run/index.d.ts.map +1 -1
  106. package/dist/types/run/index.js +1 -0
  107. package/dist/types/run/index.js.map +1 -1
  108. package/package.json +1 -1
  109. package/src/bridge/a2a/mapper.ts +3 -0
  110. package/src/bridge/sse/mapper.ts +5 -0
  111. package/src/{runtime/query → constants}/continuation.ts +5 -0
  112. package/src/constants/index.ts +1 -0
  113. package/src/manager/run/persistence.ts +34 -1
  114. package/src/provider/capabilities.ts +47 -0
  115. package/src/provider/index.ts +5 -0
  116. package/src/public-runtime.ts +4 -0
  117. package/src/run/reporter.ts +9 -0
  118. package/src/runtime/query/__tests__/capability-negotiation.test.ts +248 -0
  119. package/src/runtime/query/__tests__/checkpoint-store.test.ts +322 -0
  120. package/src/runtime/query/__tests__/checkpoint.test.ts +15 -6
  121. package/src/runtime/query/__tests__/orphan-task-turn-end.test.ts +127 -0
  122. package/src/runtime/query/checkpoint.ts +23 -9
  123. package/src/runtime/query/context.ts +9 -0
  124. package/src/runtime/query/index.ts +115 -28
  125. package/src/runtime/query/iteration/index.ts +298 -905
  126. package/src/runtime/query/iteration/phases/checkpoint.ts +25 -0
  127. package/src/runtime/query/iteration/phases/context.ts +1 -3
  128. package/src/runtime/query/iteration/stream-turn.ts +396 -0
  129. package/src/runtime/query/replay/list.ts +38 -4
  130. package/src/runtime/query/replay/prepare.ts +37 -4
  131. package/src/store/index.ts +1 -0
  132. package/src/store/run/checkpoint-disk.ts +67 -0
  133. package/src/types/provider/config.ts +18 -0
  134. package/src/types/provider/interface.ts +11 -0
  135. package/src/types/run/checkpoint-store.ts +72 -0
  136. package/src/types/run/config.ts +30 -0
  137. package/src/types/run/events.ts +13 -0
  138. package/src/types/run/index.ts +1 -0
  139. package/dist/runtime/query/continuation.d.ts.map +0 -1
  140. package/dist/runtime/query/continuation.js.map +0 -1
@@ -7,6 +7,7 @@ export * from './rag/index.js'
7
7
  export * from './tools/index.js'
8
8
  export * from './telemetry/index.js'
9
9
  export * from './agent/index.js'
10
+ export * from './continuation.js'
10
11
  export * from './limits.js'
11
12
  export * from './bus/index.js'
12
13
  export * from './verification/index.js'
@@ -1,9 +1,11 @@
1
+ import { AUTO_CONTINUATION_USER_MESSAGE } from '../../constants/continuation.js'
1
2
  import { EMPTY_TOKEN_USAGE } from '../../constants/limits.js'
2
- import { AUTO_CONTINUATION_USER_MESSAGE } from '../../runtime/query/continuation.js'
3
+ import { DiskCheckpointStore } from '../../store/run/checkpoint-disk.js'
3
4
  import { RunDiskStore } from '../../store/run/disk.js'
4
5
  import { type CostInfo, type TokenUsage, accumulateTokenUsage } from '../../types/common/index.js'
5
6
  import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
6
7
  import type { Message } from '../../types/message/index.js'
8
+ import type { CheckpointRunScope, CheckpointStore } from '../../types/run/checkpoint-store.js'
7
9
  import type { EmergencySaveData } from '../../types/run/emergency.js'
8
10
  import type { Run, RunPersistenceConfig, StopReason } from '../../types/run/index.js'
9
11
  import type { ProjectId, ThreadId } from '../../types/session/ids.js'
@@ -14,6 +16,7 @@ import type { Logger } from '../../utils/logger.js'
14
16
  export class RunPersistence {
15
17
  private run: Run
16
18
  private runStore: RunDiskStore
19
+ private checkpointStore: CheckpointStore
17
20
  private pricing?: ModelPricing
18
21
  private log: Logger
19
22
  private readonly _sessionId: SessionId
@@ -34,6 +37,15 @@ export class RunPersistence {
34
37
  logger: config.log,
35
38
  })
36
39
 
40
+ // Checkpoints go through the injectable seam; the disk layout under
41
+ // `outputDir` (same tree the runStore writes to) stays the default.
42
+ this.checkpointStore =
43
+ config.checkpointStore ??
44
+ new DiskCheckpointStore({
45
+ baseDir: config.outputDir,
46
+ logger: config.log,
47
+ })
48
+
37
49
  this.run = {
38
50
  id: config.runId,
39
51
  status: 'idle',
@@ -109,6 +121,27 @@ export class RunPersistence {
109
121
  return this.runStore
110
122
  }
111
123
 
124
+ /**
125
+ * Checkpoint persistence for this run — the injected
126
+ * {@link CheckpointStore} when the host provided one, otherwise the
127
+ * disk default. Pair with {@link getRunScope} when constructing a
128
+ * `CheckpointManager`.
129
+ */
130
+ getCheckpointStore(): CheckpointStore {
131
+ return this.checkpointStore
132
+ }
133
+
134
+ /** Full five-layer scope key for this run's checkpoint operations. */
135
+ getRunScope(): CheckpointRunScope {
136
+ return {
137
+ tenantId: this._tenantId,
138
+ projectId: this._projectId,
139
+ sessionId: this._sessionId,
140
+ runId: this.run.id,
141
+ parentRunId: this.run.parentRunId,
142
+ }
143
+ }
144
+
112
145
  getRunDir(): string | null {
113
146
  return this.runStore.getRunDir()
114
147
  }
@@ -0,0 +1,47 @@
1
+ import type { LLMProvider, ProviderCapabilities } from '../types/provider/index.js'
2
+
3
+ /**
4
+ * Fully-resolved capability set: every flag present, no optionals left.
5
+ * What the query runtime branches on after applying the permissive default.
6
+ */
7
+ export interface ResolvedProviderCapabilities {
8
+ supportsTools: boolean
9
+ supportsStreaming: boolean
10
+ supportsFunctionCalling: boolean
11
+ supportsVision: boolean
12
+ maxOutputTokens?: number
13
+ }
14
+
15
+ /**
16
+ * THE permissive default: a provider that declares nothing is assumed to
17
+ * handle everything — exactly the pre-negotiation behavior, so third-party
18
+ * providers written before `LLMProvider.capabilities` existed keep working
19
+ * without a warning storm. Shipped drivers always declare explicitly.
20
+ */
21
+ export const PERMISSIVE_PROVIDER_CAPABILITIES: ResolvedProviderCapabilities = {
22
+ supportsTools: true,
23
+ supportsStreaming: true,
24
+ supportsFunctionCalling: true,
25
+ supportsVision: true,
26
+ }
27
+
28
+ /**
29
+ * Resolve a provider's declared capabilities against the permissive
30
+ * default. Absent declaration ⇒ everything `true`; a partial declaration
31
+ * (e.g. one missing `supportsVision`, added later) fills the gap
32
+ * permissively per field.
33
+ */
34
+ export function resolveProviderCapabilities(
35
+ provider: Pick<LLMProvider, 'capabilities'>,
36
+ ): ResolvedProviderCapabilities {
37
+ const declared: ProviderCapabilities | undefined = provider.capabilities
38
+ return {
39
+ supportsTools: declared?.supportsTools ?? PERMISSIVE_PROVIDER_CAPABILITIES.supportsTools,
40
+ supportsStreaming:
41
+ declared?.supportsStreaming ?? PERMISSIVE_PROVIDER_CAPABILITIES.supportsStreaming,
42
+ supportsFunctionCalling:
43
+ declared?.supportsFunctionCalling ?? PERMISSIVE_PROVIDER_CAPABILITIES.supportsFunctionCalling,
44
+ supportsVision: declared?.supportsVision ?? PERMISSIVE_PROVIDER_CAPABILITIES.supportsVision,
45
+ maxOutputTokens: declared?.maxOutputTokens,
46
+ }
47
+ }
@@ -1,3 +1,8 @@
1
1
  export { ProviderRegistry, UnknownProviderError, DuplicateProviderError } from './registry.js'
2
2
  export { MockLLMProvider } from './mock.js'
3
3
  export { registerMock, MOCK_CAPABILITIES } from './mock-register.js'
4
+ export {
5
+ PERMISSIVE_PROVIDER_CAPABILITIES,
6
+ resolveProviderCapabilities,
7
+ } from './capabilities.js'
8
+ export type { ResolvedProviderCapabilities } from './capabilities.js'
@@ -99,6 +99,7 @@ export {
99
99
 
100
100
  export {
101
101
  ActivityStore,
102
+ DiskCheckpointStore,
102
103
  DiskMemoryStore,
103
104
  DiskTaskStore,
104
105
  InMemoryMemoryIndex,
@@ -146,8 +147,10 @@ export {
146
147
  DuplicateProviderError,
147
148
  MOCK_CAPABILITIES,
148
149
  MockLLMProvider,
150
+ PERMISSIVE_PROVIDER_CAPABILITIES,
149
151
  ProviderRegistry,
150
152
  registerMock,
153
+ resolveProviderCapabilities,
151
154
  UnknownProviderError,
152
155
  } from './provider/index.js'
153
156
 
@@ -248,6 +251,7 @@ export {
248
251
 
249
252
  export { wrapProviderWithProbes } from './provider/instrumentation.js'
250
253
  export type { ProviderInstrumentationOptions } from './provider/instrumentation.js'
254
+ export type { ResolvedProviderCapabilities } from './provider/capabilities.js'
251
255
  export { collect } from './provider/collect.js'
252
256
 
253
257
  export { wrapVaultWithProbes } from './vault/instrumentation.js'
@@ -225,6 +225,15 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
225
225
  })
226
226
  break
227
227
 
228
+ case 'capability_warning':
229
+ log.warn('Provider capability mismatch', {
230
+ runId: event.runId,
231
+ capability: event.capability,
232
+ providerId: event.providerId,
233
+ message: event.message,
234
+ })
235
+ break
236
+
228
237
  default: {
229
238
  const _exhaustive: never = event
230
239
  throw new Error(`Unhandled run event type: ${(_exhaustive as RunEvent).type}`)
@@ -0,0 +1,248 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { z } from 'zod'
6
+
7
+ import {
8
+ PERMISSIVE_PROVIDER_CAPABILITIES,
9
+ resolveProviderCapabilities,
10
+ } from '../../../provider/capabilities.js'
11
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
12
+ import type { SessionId, TenantId } from '../../../types/ids/index.js'
13
+ import { createUserMessage } from '../../../types/message/index.js'
14
+ import type {
15
+ ChatCompletionParams,
16
+ LLMProvider,
17
+ ProviderCapabilities,
18
+ StreamChunk,
19
+ } from '../../../types/provider/index.js'
20
+ import type { RunEvent } from '../../../types/run/index.js'
21
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
22
+ import { drainQuery } from '../index.js'
23
+
24
+ const ZERO_USAGE = {
25
+ promptTokens: 0,
26
+ completionTokens: 0,
27
+ totalTokens: 0,
28
+ cachedTokens: 0,
29
+ cacheWriteTokens: 0,
30
+ }
31
+
32
+ class CapturingProvider implements LLMProvider {
33
+ readonly id = 'capturing'
34
+ readonly name = 'Capturing Provider'
35
+ readonly capabilities?: ProviderCapabilities
36
+ lastParams?: ChatCompletionParams
37
+
38
+ constructor(capabilities?: ProviderCapabilities) {
39
+ this.capabilities = capabilities
40
+ }
41
+
42
+ async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
43
+ this.lastParams = params
44
+ yield { id: 'msg_1', delta: { content: 'done' } }
45
+ yield { id: 'msg_1', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
46
+ }
47
+ }
48
+
49
+ const NO_TOOLS_CAPABILITIES: ProviderCapabilities = {
50
+ supportsTools: false,
51
+ supportsStreaming: true,
52
+ supportsFunctionCalling: false,
53
+ supportsVision: true,
54
+ }
55
+
56
+ const NO_VISION_CAPABILITIES: ProviderCapabilities = {
57
+ supportsTools: true,
58
+ supportsStreaming: true,
59
+ supportsFunctionCalling: true,
60
+ supportsVision: false,
61
+ }
62
+
63
+ function registerEchoTool(tools: ToolRegistry): void {
64
+ tools.register({
65
+ name: 'echo',
66
+ description: 'Echo the text back.',
67
+ inputSchema: z.object({ text: z.string() }),
68
+ execute: async () => ({ success: true, output: 'ok' }),
69
+ })
70
+ }
71
+
72
+ function baseParams(provider: LLMProvider, tools: ToolRegistry, workingDirectory: string) {
73
+ return {
74
+ provider,
75
+ tools,
76
+ runConfig: {
77
+ model: 'mock-model',
78
+ timeoutMs: 5_000,
79
+ tokenBudget: 100_000,
80
+ maxIterations: 1,
81
+ maxResponseTokens: 256,
82
+ },
83
+ agentId: 'agent_test',
84
+ agentName: 'Test Agent',
85
+ workingDirectory,
86
+ sessionId: 'ses_capability' as SessionId,
87
+ threadId: 'thd_capability' as ThreadId,
88
+ projectId: 'prj_capability' as ProjectId,
89
+ tenantId: 'tnt_capability' as TenantId,
90
+ }
91
+ }
92
+
93
+ describe('resolveProviderCapabilities', () => {
94
+ it('resolves an undeclared provider to the permissive default', () => {
95
+ expect(resolveProviderCapabilities(new CapturingProvider())).toEqual(
96
+ PERMISSIVE_PROVIDER_CAPABILITIES,
97
+ )
98
+ })
99
+
100
+ it('fills a partial declaration (missing supportsVision) permissively per field', () => {
101
+ const resolved = resolveProviderCapabilities({
102
+ capabilities: {
103
+ supportsTools: false,
104
+ supportsStreaming: true,
105
+ supportsFunctionCalling: false,
106
+ },
107
+ })
108
+ expect(resolved.supportsTools).toBe(false)
109
+ expect(resolved.supportsVision).toBe(true)
110
+ })
111
+ })
112
+
113
+ describe('query() capability negotiation', () => {
114
+ let workdirs: string[] = []
115
+
116
+ afterEach(async () => {
117
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
118
+ workdirs = []
119
+ })
120
+
121
+ async function mkWorkdir(): Promise<string> {
122
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-capability-'))
123
+ workdirs.push(dir)
124
+ return dir
125
+ }
126
+
127
+ it('strips tool surfaces and emits a capability_warning for a no-tools provider', async () => {
128
+ const provider = new CapturingProvider(NO_TOOLS_CAPABILITIES)
129
+ const tools = new ToolRegistry()
130
+ registerEchoTool(tools)
131
+ const events: RunEvent[] = []
132
+
133
+ const run = await drainQuery(
134
+ {
135
+ ...baseParams(provider, tools, await mkWorkdir()),
136
+ messages: [createUserMessage('hello')],
137
+ },
138
+ (event) => {
139
+ events.push(event)
140
+ },
141
+ )
142
+
143
+ expect(run.status).toBe('completed')
144
+
145
+ // The model was never told about tools: no tools param on the wire…
146
+ expect(provider.lastParams?.tools).toBeUndefined()
147
+ // …and no tool section in the system prompt.
148
+ const systemPrompt = (provider.lastParams?.messages ?? [])
149
+ .filter((m) => m.role === 'system')
150
+ .map((m) => m.content)
151
+ .join('\n')
152
+ expect(systemPrompt).not.toContain('<available_tools>')
153
+ expect(systemPrompt).not.toContain('echo')
154
+
155
+ // The host got the machine-readable warning.
156
+ const warning = events.find(
157
+ (e): e is Extract<RunEvent, { type: 'capability_warning' }> =>
158
+ e.type === 'capability_warning',
159
+ )
160
+ expect(warning?.capability).toBe('tools')
161
+ expect(warning?.providerId).toBe('capturing')
162
+ expect(warning?.message).toContain('stripped')
163
+ })
164
+
165
+ it('keeps tool surfaces intact for an undeclared (permissive-default) provider', async () => {
166
+ const provider = new CapturingProvider()
167
+ const tools = new ToolRegistry()
168
+ registerEchoTool(tools)
169
+
170
+ const run = await drainQuery({
171
+ ...baseParams(provider, tools, await mkWorkdir()),
172
+ messages: [createUserMessage('hello')],
173
+ })
174
+
175
+ expect(run.status).toBe('completed')
176
+ expect(provider.lastParams?.tools?.map((t) => t.function.name)).toContain('echo')
177
+ })
178
+
179
+ it('emits a vision capability_warning when attachments hit a no-vision provider', async () => {
180
+ const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
181
+ const events: RunEvent[] = []
182
+
183
+ const run = await drainQuery(
184
+ {
185
+ ...baseParams(provider, new ToolRegistry(), await mkWorkdir()),
186
+ messages: [
187
+ createUserMessage('what is in this image?', [
188
+ { data: 'aGVsbG8=', mediaType: 'image/png' },
189
+ ]),
190
+ ],
191
+ },
192
+ (event) => {
193
+ events.push(event)
194
+ },
195
+ )
196
+
197
+ expect(run.status).toBe('completed')
198
+ const warning = events.find(
199
+ (e): e is Extract<RunEvent, { type: 'capability_warning' }> =>
200
+ e.type === 'capability_warning',
201
+ )
202
+ expect(warning?.capability).toBe('vision')
203
+ expect(warning?.providerId).toBe('capturing')
204
+ })
205
+
206
+ it('does not warn when no attachments are present on a no-vision provider', async () => {
207
+ const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
208
+ const events: RunEvent[] = []
209
+
210
+ await drainQuery(
211
+ {
212
+ ...baseParams(provider, new ToolRegistry(), await mkWorkdir()),
213
+ messages: [createUserMessage('plain text')],
214
+ },
215
+ (event) => {
216
+ events.push(event)
217
+ },
218
+ )
219
+
220
+ expect(events.some((e) => e.type === 'capability_warning')).toBe(false)
221
+ })
222
+
223
+ it('strictCapabilities: true throws on a tools mismatch instead of degrading', async () => {
224
+ const provider = new CapturingProvider(NO_TOOLS_CAPABILITIES)
225
+ const tools = new ToolRegistry()
226
+ registerEchoTool(tools)
227
+
228
+ await expect(
229
+ drainQuery({
230
+ ...baseParams(provider, tools, await mkWorkdir()),
231
+ messages: [createUserMessage('hello')],
232
+ strictCapabilities: true,
233
+ }),
234
+ ).rejects.toThrow(/supportsTools: false/)
235
+ })
236
+
237
+ it('strictCapabilities: true throws on a vision mismatch instead of degrading', async () => {
238
+ const provider = new CapturingProvider(NO_VISION_CAPABILITIES)
239
+
240
+ await expect(
241
+ drainQuery({
242
+ ...baseParams(provider, new ToolRegistry(), await mkWorkdir()),
243
+ messages: [createUserMessage('describe', [{ data: 'aGVsbG8=', mediaType: 'image/png' }])],
244
+ strictCapabilities: true,
245
+ }),
246
+ ).rejects.toThrow(/supportsVision: false/)
247
+ })
248
+ })