@namzu/sdk 0.4.2 → 0.4.3

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 (207) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/advisory/context.test.d.ts +16 -0
  3. package/dist/advisory/context.test.d.ts.map +1 -0
  4. package/dist/advisory/context.test.js +92 -0
  5. package/dist/advisory/context.test.js.map +1 -0
  6. package/dist/advisory/evaluator.test.d.ts +34 -0
  7. package/dist/advisory/evaluator.test.d.ts.map +1 -0
  8. package/dist/advisory/evaluator.test.js +172 -0
  9. package/dist/advisory/evaluator.test.js.map +1 -0
  10. package/dist/advisory/executor.test.d.ts +35 -0
  11. package/dist/advisory/executor.test.d.ts.map +1 -0
  12. package/dist/advisory/executor.test.js +233 -0
  13. package/dist/advisory/executor.test.js.map +1 -0
  14. package/dist/advisory/registry.test.d.ts +16 -0
  15. package/dist/advisory/registry.test.d.ts.map +1 -0
  16. package/dist/advisory/registry.test.js +62 -0
  17. package/dist/advisory/registry.test.js.map +1 -0
  18. package/dist/bridge/a2a/agent-card.test.d.ts +24 -0
  19. package/dist/bridge/a2a/agent-card.test.d.ts.map +1 -0
  20. package/dist/bridge/a2a/agent-card.test.js +118 -0
  21. package/dist/bridge/a2a/agent-card.test.js.map +1 -0
  22. package/dist/bridge/a2a/mapper.test.d.ts +29 -0
  23. package/dist/bridge/a2a/mapper.test.d.ts.map +1 -0
  24. package/dist/bridge/a2a/mapper.test.js +265 -0
  25. package/dist/bridge/a2a/mapper.test.js.map +1 -0
  26. package/dist/bridge/a2a/message.test.d.ts +20 -0
  27. package/dist/bridge/a2a/message.test.d.ts.map +1 -0
  28. package/dist/bridge/a2a/message.test.js +116 -0
  29. package/dist/bridge/a2a/message.test.js.map +1 -0
  30. package/dist/bridge/a2a/task.test.d.ts +29 -0
  31. package/dist/bridge/a2a/task.test.d.ts.map +1 -0
  32. package/dist/bridge/a2a/task.test.js +198 -0
  33. package/dist/bridge/a2a/task.test.js.map +1 -0
  34. package/dist/bridge/mcp/connector/adapter.test.d.ts +27 -0
  35. package/dist/bridge/mcp/connector/adapter.test.d.ts.map +1 -0
  36. package/dist/bridge/mcp/connector/adapter.test.js +203 -0
  37. package/dist/bridge/mcp/connector/adapter.test.js.map +1 -0
  38. package/dist/bridge/sse/mapper.test.d.ts +27 -0
  39. package/dist/bridge/sse/mapper.test.d.ts.map +1 -0
  40. package/dist/bridge/sse/mapper.test.js +271 -0
  41. package/dist/bridge/sse/mapper.test.js.map +1 -0
  42. package/dist/bridge/tools/connector/adapter.test.d.ts +28 -0
  43. package/dist/bridge/tools/connector/adapter.test.d.ts.map +1 -0
  44. package/dist/bridge/tools/connector/adapter.test.js +182 -0
  45. package/dist/bridge/tools/connector/adapter.test.js.map +1 -0
  46. package/dist/bridge/tools/connector/definitions.test.d.ts +23 -0
  47. package/dist/bridge/tools/connector/definitions.test.d.ts.map +1 -0
  48. package/dist/bridge/tools/connector/definitions.test.js +158 -0
  49. package/dist/bridge/tools/connector/definitions.test.js.map +1 -0
  50. package/dist/bridge/tools/connector/router.test.d.ts +21 -0
  51. package/dist/bridge/tools/connector/router.test.d.ts.map +1 -0
  52. package/dist/bridge/tools/connector/router.test.js +139 -0
  53. package/dist/bridge/tools/connector/router.test.js.map +1 -0
  54. package/dist/bus/breaker.test.d.ts +41 -0
  55. package/dist/bus/breaker.test.d.ts.map +1 -0
  56. package/dist/bus/breaker.test.js +242 -0
  57. package/dist/bus/breaker.test.js.map +1 -0
  58. package/dist/bus/index.test.d.ts +25 -0
  59. package/dist/bus/index.test.d.ts.map +1 -0
  60. package/dist/bus/index.test.js +151 -0
  61. package/dist/bus/index.test.js.map +1 -0
  62. package/dist/bus/lock.test.d.ts +44 -0
  63. package/dist/bus/lock.test.d.ts.map +1 -0
  64. package/dist/bus/lock.test.js +226 -0
  65. package/dist/bus/lock.test.js.map +1 -0
  66. package/dist/bus/ownership.test.d.ts +26 -0
  67. package/dist/bus/ownership.test.d.ts.map +1 -0
  68. package/dist/bus/ownership.test.js +205 -0
  69. package/dist/bus/ownership.test.js.map +1 -0
  70. package/dist/connector/BaseConnector.test.d.ts +21 -0
  71. package/dist/connector/BaseConnector.test.d.ts.map +1 -0
  72. package/dist/connector/BaseConnector.test.js +108 -0
  73. package/dist/connector/BaseConnector.test.js.map +1 -0
  74. package/dist/connector/builtins/http.test.d.ts +30 -0
  75. package/dist/connector/builtins/http.test.d.ts.map +1 -0
  76. package/dist/connector/builtins/http.test.js +232 -0
  77. package/dist/connector/builtins/http.test.js.map +1 -0
  78. package/dist/connector/builtins/webhook.test.d.ts +20 -0
  79. package/dist/connector/builtins/webhook.test.d.ts.map +1 -0
  80. package/dist/connector/builtins/webhook.test.js +113 -0
  81. package/dist/connector/builtins/webhook.test.js.map +1 -0
  82. package/dist/connector/execution/factory.test.d.ts +16 -0
  83. package/dist/connector/execution/factory.test.d.ts.map +1 -0
  84. package/dist/connector/execution/factory.test.js +64 -0
  85. package/dist/connector/execution/factory.test.js.map +1 -0
  86. package/dist/connector/execution/remote.test.d.ts +16 -0
  87. package/dist/connector/execution/remote.test.d.ts.map +1 -0
  88. package/dist/connector/execution/remote.test.js +53 -0
  89. package/dist/connector/execution/remote.test.js.map +1 -0
  90. package/dist/connector/mcp/adapter.test.d.ts +34 -0
  91. package/dist/connector/mcp/adapter.test.d.ts.map +1 -0
  92. package/dist/connector/mcp/adapter.test.js +199 -0
  93. package/dist/connector/mcp/adapter.test.js.map +1 -0
  94. package/dist/rag/chunking.test.d.ts +20 -0
  95. package/dist/rag/chunking.test.d.ts.map +1 -0
  96. package/dist/rag/chunking.test.js +92 -0
  97. package/dist/rag/chunking.test.js.map +1 -0
  98. package/dist/rag/context-assembler.test.d.ts +19 -0
  99. package/dist/rag/context-assembler.test.d.ts.map +1 -0
  100. package/dist/rag/context-assembler.test.js +98 -0
  101. package/dist/rag/context-assembler.test.js.map +1 -0
  102. package/dist/rag/embedding.test.d.ts +19 -0
  103. package/dist/rag/embedding.test.d.ts.map +1 -0
  104. package/dist/rag/embedding.test.js +115 -0
  105. package/dist/rag/embedding.test.js.map +1 -0
  106. package/dist/rag/ingestion.test.d.ts +22 -0
  107. package/dist/rag/ingestion.test.d.ts.map +1 -0
  108. package/dist/rag/ingestion.test.js +99 -0
  109. package/dist/rag/ingestion.test.js.map +1 -0
  110. package/dist/rag/knowledge-base.test.d.ts +17 -0
  111. package/dist/rag/knowledge-base.test.d.ts.map +1 -0
  112. package/dist/rag/knowledge-base.test.js +77 -0
  113. package/dist/rag/knowledge-base.test.js.map +1 -0
  114. package/dist/rag/rag-tool.test.d.ts +21 -0
  115. package/dist/rag/rag-tool.test.d.ts.map +1 -0
  116. package/dist/rag/rag-tool.test.js +149 -0
  117. package/dist/rag/rag-tool.test.js.map +1 -0
  118. package/dist/rag/retriever.test.d.ts +26 -0
  119. package/dist/rag/retriever.test.d.ts.map +1 -0
  120. package/dist/rag/retriever.test.js +180 -0
  121. package/dist/rag/retriever.test.js.map +1 -0
  122. package/dist/rag/vector-store.test.d.ts +38 -0
  123. package/dist/rag/vector-store.test.d.ts.map +1 -0
  124. package/dist/rag/vector-store.test.js +175 -0
  125. package/dist/rag/vector-store.test.js.map +1 -0
  126. package/dist/registry/ManagedRegistry.test.d.ts +21 -0
  127. package/dist/registry/ManagedRegistry.test.d.ts.map +1 -0
  128. package/dist/registry/ManagedRegistry.test.js +98 -0
  129. package/dist/registry/ManagedRegistry.test.js.map +1 -0
  130. package/dist/registry/Registry.test.d.ts +18 -0
  131. package/dist/registry/Registry.test.d.ts.map +1 -0
  132. package/dist/registry/Registry.test.js +79 -0
  133. package/dist/registry/Registry.test.js.map +1 -0
  134. package/dist/registry/agent/definitions.test.d.ts +15 -0
  135. package/dist/registry/agent/definitions.test.d.ts.map +1 -0
  136. package/dist/registry/agent/definitions.test.js +84 -0
  137. package/dist/registry/agent/definitions.test.js.map +1 -0
  138. package/dist/registry/connector/definitions.test.d.ts +13 -0
  139. package/dist/registry/connector/definitions.test.d.ts.map +1 -0
  140. package/dist/registry/connector/definitions.test.js +41 -0
  141. package/dist/registry/connector/definitions.test.js.map +1 -0
  142. package/dist/registry/connector/scoped.test.d.ts +21 -0
  143. package/dist/registry/connector/scoped.test.d.ts.map +1 -0
  144. package/dist/registry/connector/scoped.test.js +115 -0
  145. package/dist/registry/connector/scoped.test.js.map +1 -0
  146. package/dist/registry/plugin/index.test.d.ts +12 -0
  147. package/dist/registry/plugin/index.test.d.ts.map +1 -0
  148. package/dist/registry/plugin/index.test.js +69 -0
  149. package/dist/registry/plugin/index.test.js.map +1 -0
  150. package/dist/registry/tool/execute.test.d.ts +42 -0
  151. package/dist/registry/tool/execute.test.d.ts.map +1 -0
  152. package/dist/registry/tool/execute.test.js +281 -0
  153. package/dist/registry/tool/execute.test.js.map +1 -0
  154. package/dist/runtime/query/iteration/phases/advisory.test.d.ts +42 -0
  155. package/dist/runtime/query/iteration/phases/advisory.test.d.ts.map +1 -0
  156. package/dist/runtime/query/iteration/phases/advisory.test.js +334 -0
  157. package/dist/runtime/query/iteration/phases/advisory.test.js.map +1 -0
  158. package/dist/test-setup.d.ts +22 -0
  159. package/dist/test-setup.d.ts.map +1 -0
  160. package/dist/test-setup.js +23 -0
  161. package/dist/test-setup.js.map +1 -0
  162. package/dist/utils/logger.d.ts +1 -1
  163. package/dist/utils/logger.d.ts.map +1 -1
  164. package/dist/utils/logger.js +5 -0
  165. package/dist/utils/logger.js.map +1 -1
  166. package/package.json +4 -1
  167. package/src/advisory/context.test.ts +109 -0
  168. package/src/advisory/evaluator.test.ts +192 -0
  169. package/src/advisory/executor.test.ts +272 -0
  170. package/src/advisory/registry.test.ts +75 -0
  171. package/src/bridge/a2a/agent-card.test.ts +140 -0
  172. package/src/bridge/a2a/mapper.test.ts +293 -0
  173. package/src/bridge/a2a/message.test.ts +138 -0
  174. package/src/bridge/a2a/task.test.ts +235 -0
  175. package/src/bridge/mcp/connector/adapter.test.ts +230 -0
  176. package/src/bridge/sse/mapper.test.ts +422 -0
  177. package/src/bridge/tools/connector/adapter.test.ts +224 -0
  178. package/src/bridge/tools/connector/definitions.test.ts +183 -0
  179. package/src/bridge/tools/connector/router.test.ts +159 -0
  180. package/src/bus/breaker.test.ts +274 -0
  181. package/src/bus/index.test.ts +183 -0
  182. package/src/bus/lock.test.ts +265 -0
  183. package/src/bus/ownership.test.ts +243 -0
  184. package/src/connector/BaseConnector.test.ts +130 -0
  185. package/src/connector/builtins/http.test.ts +290 -0
  186. package/src/connector/builtins/webhook.test.ts +138 -0
  187. package/src/connector/execution/factory.test.ts +75 -0
  188. package/src/connector/execution/remote.test.ts +63 -0
  189. package/src/connector/mcp/adapter.test.ts +249 -0
  190. package/src/rag/chunking.test.ts +107 -0
  191. package/src/rag/context-assembler.test.ts +114 -0
  192. package/src/rag/embedding.test.ts +130 -0
  193. package/src/rag/ingestion.test.ts +114 -0
  194. package/src/rag/knowledge-base.test.ts +106 -0
  195. package/src/rag/rag-tool.test.ts +167 -0
  196. package/src/rag/retriever.test.ts +210 -0
  197. package/src/rag/vector-store.test.ts +196 -0
  198. package/src/registry/ManagedRegistry.test.ts +118 -0
  199. package/src/registry/Registry.test.ts +91 -0
  200. package/src/registry/agent/definitions.test.ts +100 -0
  201. package/src/registry/connector/definitions.test.ts +51 -0
  202. package/src/registry/connector/scoped.test.ts +129 -0
  203. package/src/registry/plugin/index.test.ts +85 -0
  204. package/src/registry/tool/execute.test.ts +330 -0
  205. package/src/runtime/query/iteration/phases/advisory.test.ts +412 -0
  206. package/src/test-setup.ts +24 -0
  207. package/src/utils/logger.ts +6 -1
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Current-code invariants asserted (2026-04-21, ses_006 Phase 5):
3
+ *
4
+ * - `mcpJsonSchemaToZod(schema)`:
5
+ * - Empty / no-properties object → passthrough `z.object({})`.
6
+ * - Maps primitive types (string, number, integer, boolean, array,
7
+ * object) to zod equivalents.
8
+ * - Unknown / null property types → `z.unknown()`.
9
+ * - Fields not in `required[]` are `.optional()`.
10
+ * - `zodToMCPJsonSchema(zodSchema)` wraps `zodToJsonSchema` with
11
+ * `type: 'object'` prefixed.
12
+ * - `mcpToolToToolDefinition(tool, client, serverName)` produces a
13
+ * ToolDefinition:
14
+ * - name = `mcp_<server>_<tool.name>`
15
+ * - description prefixed `[MCP:<server>] <tool.description or name>`
16
+ * - category = 'network', permissions = ['network_access']
17
+ * - readOnly + destructive reflect MCP annotations; concurrency
18
+ * safe defaults to true.
19
+ * - `execute(input, ctx)` calls `client.callTool(tool.name, input)`
20
+ * and adapts the MCPToolResult.
21
+ * - `toolDefinitionToMCPTool(tool)` projects name/description +
22
+ * converts inputSchema + copies annotations.
23
+ * - `mcpToolResultToToolResult`:
24
+ * - success = !isError.
25
+ * - output = text content joined with '\n' (non-text blocks
26
+ * kept in `data` only).
27
+ * - error = same joined text when isError, else undefined.
28
+ * - `toolResultToMCPToolResult`:
29
+ * - success + output → single text block.
30
+ * - failure + error → single text block + isError.
31
+ * - Empty output on success → single empty-text block.
32
+ */
33
+
34
+ import { describe, expect, it, vi } from 'vitest'
35
+ import { z } from 'zod'
36
+
37
+ import type { MCPJsonSchema, MCPToolResult } from '../../types/connector/index.js'
38
+ import type { ToolContext, ToolDefinition, ToolResult } from '../../types/tool/index.js'
39
+
40
+ import {
41
+ mcpJsonSchemaToZod,
42
+ mcpToolResultToToolResult,
43
+ mcpToolToToolDefinition,
44
+ toolDefinitionToMCPTool,
45
+ toolResultToMCPToolResult,
46
+ zodToMCPJsonSchema,
47
+ } from './adapter.js'
48
+ import type { MCPClient } from './client.js'
49
+
50
+ function mockClient(result: MCPToolResult): MCPClient {
51
+ return {
52
+ callTool: vi.fn(async () => result),
53
+ } as unknown as MCPClient
54
+ }
55
+
56
+ describe('mcpJsonSchemaToZod', () => {
57
+ it('empty schema → passthrough empty object', () => {
58
+ const schema = mcpJsonSchemaToZod({ type: 'object' } as MCPJsonSchema)
59
+ expect(() => schema.parse({ extra: 'x' })).not.toThrow()
60
+ })
61
+
62
+ it('maps primitive types', () => {
63
+ const schema = mcpJsonSchemaToZod({
64
+ type: 'object',
65
+ required: ['s', 'n', 'b', 'arr', 'obj'],
66
+ properties: {
67
+ s: { type: 'string' },
68
+ n: { type: 'number' },
69
+ b: { type: 'boolean' },
70
+ arr: { type: 'array' },
71
+ obj: { type: 'object' },
72
+ },
73
+ } as MCPJsonSchema)
74
+ expect(() => schema.parse({ s: 'x', n: 1, b: true, arr: [], obj: {} })).not.toThrow()
75
+ expect(() => schema.parse({ s: 123, n: 1, b: true, arr: [], obj: {} })).toThrow()
76
+ })
77
+
78
+ it('optional fields are not required', () => {
79
+ const schema = mcpJsonSchemaToZod({
80
+ type: 'object',
81
+ required: ['a'],
82
+ properties: {
83
+ a: { type: 'string' },
84
+ b: { type: 'string' },
85
+ },
86
+ } as MCPJsonSchema)
87
+ expect(() => schema.parse({ a: 'x' })).not.toThrow()
88
+ })
89
+
90
+ it('unknown property types map to z.unknown (pass any value)', () => {
91
+ const schema = mcpJsonSchemaToZod({
92
+ type: 'object',
93
+ required: ['x'],
94
+ properties: { x: { type: 'weird' } },
95
+ } as MCPJsonSchema)
96
+ expect(() => schema.parse({ x: { nested: [1, 2] } })).not.toThrow()
97
+ })
98
+ })
99
+
100
+ describe('zodToMCPJsonSchema', () => {
101
+ it('wraps zod schema with type: object', () => {
102
+ const out = zodToMCPJsonSchema(z.object({ a: z.string() }))
103
+ expect(out.type).toBe('object')
104
+ })
105
+ })
106
+
107
+ describe('mcpToolToToolDefinition', () => {
108
+ it('prefixes name + description with the server handle', () => {
109
+ const tool = mcpToolToToolDefinition(
110
+ {
111
+ name: 'search',
112
+ description: 'search docs',
113
+ inputSchema: { type: 'object' } as MCPJsonSchema,
114
+ },
115
+ mockClient({ content: [{ type: 'text', text: 'ok' }], isError: false }),
116
+ 'serverA',
117
+ )
118
+ expect(tool.name).toBe('mcp_serverA_search')
119
+ expect(tool.description).toBe('[MCP:serverA] search docs')
120
+ expect(tool.category).toBe('network')
121
+ })
122
+
123
+ it('uses tool.name as description fallback when no description', () => {
124
+ const tool = mcpToolToToolDefinition(
125
+ { name: 'search', inputSchema: { type: 'object' } as MCPJsonSchema },
126
+ mockClient({ content: [{ type: 'text', text: 'ok' }], isError: false }),
127
+ 'serverA',
128
+ )
129
+ expect(tool.description).toBe('[MCP:serverA] search')
130
+ })
131
+
132
+ it('reflects MCP annotations into tool flags', () => {
133
+ const tool = mcpToolToToolDefinition(
134
+ {
135
+ name: 't',
136
+ inputSchema: { type: 'object' } as MCPJsonSchema,
137
+ annotations: { readOnlyHint: true, destructiveHint: true },
138
+ },
139
+ mockClient({ content: [], isError: false }),
140
+ 's',
141
+ )
142
+ expect(tool.isReadOnly?.({})).toBe(true)
143
+ expect(tool.isDestructive?.({})).toBe(true)
144
+ expect(tool.isConcurrencySafe?.({})).toBe(true)
145
+ })
146
+
147
+ it('default flags when annotations are absent', () => {
148
+ const tool = mcpToolToToolDefinition(
149
+ { name: 't', inputSchema: { type: 'object' } as MCPJsonSchema },
150
+ mockClient({ content: [], isError: false }),
151
+ 's',
152
+ )
153
+ expect(tool.isReadOnly?.({})).toBe(false)
154
+ expect(tool.isDestructive?.({})).toBe(false)
155
+ })
156
+
157
+ it('execute calls client.callTool(tool.name, input) and adapts result', async () => {
158
+ const client = mockClient({
159
+ content: [{ type: 'text', text: 'hello' }],
160
+ isError: false,
161
+ })
162
+ const tool = mcpToolToToolDefinition(
163
+ { name: 'search', inputSchema: { type: 'object' } as MCPJsonSchema },
164
+ client,
165
+ 's',
166
+ )
167
+ const result = await tool.execute({ q: 'hi' }, {} as ToolContext)
168
+ expect(client.callTool).toHaveBeenCalledWith('search', { q: 'hi' })
169
+ expect(result.success).toBe(true)
170
+ expect(result.output).toBe('hello')
171
+ })
172
+ })
173
+
174
+ describe('toolDefinitionToMCPTool', () => {
175
+ it('projects name / description / inputSchema / annotations', () => {
176
+ const tool: ToolDefinition = {
177
+ name: 't',
178
+ description: 'd',
179
+ inputSchema: z.object({ a: z.string() }),
180
+ async execute() {
181
+ return { success: true, output: '' }
182
+ },
183
+ isReadOnly: () => true,
184
+ isDestructive: () => false,
185
+ }
186
+ const out = toolDefinitionToMCPTool(tool)
187
+ expect(out.name).toBe('t')
188
+ expect(out.description).toBe('d')
189
+ expect(out.inputSchema.type).toBe('object')
190
+ expect(out.annotations).toEqual({ readOnlyHint: true, destructiveHint: false })
191
+ })
192
+ })
193
+
194
+ describe('mcpToolResultToToolResult', () => {
195
+ it('joins text blocks with \\n for output', () => {
196
+ const result = mcpToolResultToToolResult({
197
+ content: [
198
+ { type: 'text', text: 'line 1' },
199
+ { type: 'text', text: 'line 2' },
200
+ ],
201
+ isError: false,
202
+ })
203
+ expect(result.output).toBe('line 1\nline 2')
204
+ expect(result.success).toBe(true)
205
+ })
206
+
207
+ it('filters out non-text blocks from output but keeps them in data', () => {
208
+ const result = mcpToolResultToToolResult({
209
+ content: [
210
+ { type: 'text', text: 'text' },
211
+ { type: 'image', data: 'b64', mimeType: 'image/png' },
212
+ ],
213
+ isError: false,
214
+ })
215
+ expect(result.output).toBe('text')
216
+ expect(Array.isArray(result.data)).toBe(true)
217
+ })
218
+
219
+ it('sets error field when isError is true', () => {
220
+ const result = mcpToolResultToToolResult({
221
+ content: [{ type: 'text', text: 'boom' }],
222
+ isError: true,
223
+ })
224
+ expect(result.success).toBe(false)
225
+ expect(result.error).toBe('boom')
226
+ })
227
+ })
228
+
229
+ describe('toolResultToMCPToolResult', () => {
230
+ it('success + output → single text block', () => {
231
+ const result: ToolResult = { success: true, output: 'ok' }
232
+ const out = toolResultToMCPToolResult(result)
233
+ expect(out.content).toEqual([{ type: 'text', text: 'ok' }])
234
+ expect(out.isError).toBe(false)
235
+ })
236
+
237
+ it('failure + error → text block + isError', () => {
238
+ const result: ToolResult = { success: false, output: '', error: 'boom' }
239
+ const out = toolResultToMCPToolResult(result)
240
+ expect(out.content.some((b) => b.type === 'text' && b.text === 'boom')).toBe(true)
241
+ expect(out.isError).toBe(true)
242
+ })
243
+
244
+ it('success with empty output → one empty-text block', () => {
245
+ const result: ToolResult = { success: true, output: '' }
246
+ const out = toolResultToMCPToolResult(result)
247
+ expect(out.content).toEqual([{ type: 'text', text: '' }])
248
+ })
249
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Current-code invariants asserted (2026-04-21, ses_006 Phase 4):
3
+ *
4
+ * - `TextChunker.chunk(content, config)` dispatches by
5
+ * `config.strategy`. Unknown strategy hits an exhaustive-check
6
+ * throw (unreachable via types).
7
+ * - `fixed` strategy: slides a window of `chunkSize` by
8
+ * `chunkSize − chunkOverlap` (min 1) and emits trimmed non-empty
9
+ * slices. Indices are 0-based and contiguous.
10
+ * - `sentence` / `paragraph` strategies split by their separator
11
+ * sets and then merge small parts until the budget fills.
12
+ * - `recursive` strategy: short content returns as a single chunk;
13
+ * otherwise it splits by the first separator that produces >1
14
+ * parts, merges, and recurses into parts that still exceed
15
+ * `chunkSize`. Falls back to `fixed` when no separator splits.
16
+ * - Overlap is applied in `mergeSmallParts` via
17
+ * `current.slice(current.length - chunkOverlap) + part`.
18
+ */
19
+
20
+ import { describe, expect, it } from 'vitest'
21
+
22
+ import type { ChunkingConfig } from '../types/rag/index.js'
23
+
24
+ import { TextChunker } from './chunking.js'
25
+
26
+ const chunker = new TextChunker()
27
+
28
+ describe('TextChunker — fixed', () => {
29
+ const fixed: ChunkingConfig = { strategy: 'fixed', chunkSize: 10, chunkOverlap: 2 }
30
+
31
+ it('emits trimmed non-empty slices with contiguous indices', () => {
32
+ const result = chunker.chunk('0123456789abcdefghij', fixed)
33
+ expect(result).toHaveLength(3)
34
+ expect(result.map((c) => c.index)).toEqual([0, 1, 2])
35
+ })
36
+
37
+ it('skips whitespace-only slices', () => {
38
+ const result = chunker.chunk('abc def', {
39
+ strategy: 'fixed',
40
+ chunkSize: 5,
41
+ chunkOverlap: 0,
42
+ })
43
+ expect(result.map((c) => c.content)).not.toContain('')
44
+ })
45
+
46
+ it('clamps step to at least 1 when overlap >= chunkSize', () => {
47
+ const result = chunker.chunk('abcdefghij', {
48
+ strategy: 'fixed',
49
+ chunkSize: 3,
50
+ chunkOverlap: 3,
51
+ })
52
+ // step = max(1, 3-3) = 1; eagerly emits many overlapping slices
53
+ expect(result.length).toBeGreaterThan(1)
54
+ })
55
+ })
56
+
57
+ describe('TextChunker — sentence', () => {
58
+ it('splits by sentence separators and merges up to the budget', () => {
59
+ const result = chunker.chunk('First sentence. Second sentence. Third sentence.', {
60
+ strategy: 'sentence',
61
+ chunkSize: 100,
62
+ chunkOverlap: 0,
63
+ })
64
+ expect(result.length).toBeGreaterThanOrEqual(1)
65
+ expect(result[0]?.content).toContain('First sentence')
66
+ })
67
+ })
68
+
69
+ describe('TextChunker — paragraph', () => {
70
+ it('splits by paragraph separators', () => {
71
+ const result = chunker.chunk('para one\n\npara two\n\npara three', {
72
+ strategy: 'paragraph',
73
+ chunkSize: 200,
74
+ chunkOverlap: 0,
75
+ })
76
+ expect(result.length).toBeGreaterThanOrEqual(1)
77
+ })
78
+ })
79
+
80
+ describe('TextChunker — recursive', () => {
81
+ it('short content fits into a single chunk', () => {
82
+ const result = chunker.chunk('tiny', {
83
+ strategy: 'recursive',
84
+ chunkSize: 100,
85
+ chunkOverlap: 0,
86
+ })
87
+ expect(result).toEqual([{ content: 'tiny', index: 0 }])
88
+ })
89
+
90
+ it('long content recursively splits to stay within chunkSize', () => {
91
+ const content = 'paragraph one. more text.\n\nparagraph two. more.'
92
+ const result = chunker.chunk(content, {
93
+ strategy: 'recursive',
94
+ chunkSize: 30,
95
+ chunkOverlap: 0,
96
+ })
97
+ for (const c of result) {
98
+ expect(c.content.length).toBeLessThanOrEqual(30)
99
+ }
100
+ })
101
+
102
+ it('empty or whitespace-only content yields empty result', () => {
103
+ expect(chunker.chunk(' ', { strategy: 'recursive', chunkSize: 10, chunkOverlap: 0 })).toEqual(
104
+ [],
105
+ )
106
+ })
107
+ })
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Current-code invariants asserted (2026-04-21, ses_006 Phase 4):
3
+ *
4
+ * - `assembleRAGContext([], cfg)` returns `{content: '', sources: [],
5
+ * tokenCount: 0}` immediately.
6
+ * - Token estimation: `Math.ceil(text.length / 4)`.
7
+ * - `headerTemplate` is prepended before any chunks.
8
+ * - Chunks are included in order while their accumulated token count
9
+ * stays below `maxTokens`; the first chunk that would overflow is
10
+ * dropped along with every subsequent chunk (early break).
11
+ * - `includeMetadata: true` prefixes each chunk with a bracketed
12
+ * metadata line (`Source: …`, `Title: …`, `Relevance: XX.X%`).
13
+ * - `sources[]` captures a truncated preview (first 200 chars) of
14
+ * each INCLUDED chunk; skipped chunks are NOT present.
15
+ * - `content` is the joined parts with `config.separator`;
16
+ * `tokenCount` is re-estimated from the joined content.
17
+ */
18
+
19
+ import { describe, expect, it } from 'vitest'
20
+
21
+ import type { ChunkId, DocumentId, KnowledgeBaseId, TenantId } from '../types/ids/index.js'
22
+ import type { VectorSearchResult } from '../types/rag/index.js'
23
+
24
+ import { assembleRAGContext } from './context-assembler.js'
25
+
26
+ function result(
27
+ content: string,
28
+ score = 0.9,
29
+ meta: Record<string, unknown> = {},
30
+ ): VectorSearchResult {
31
+ return {
32
+ chunk: {
33
+ id: `c_${content.slice(0, 3)}` as ChunkId,
34
+ documentId: 'doc_1' as DocumentId,
35
+ knowledgeBaseId: 'kb_1' as KnowledgeBaseId,
36
+ tenantId: 't_1' as TenantId,
37
+ content,
38
+ index: 0,
39
+ tokenCount: 0,
40
+ metadata: meta,
41
+ createdAt: 0,
42
+ },
43
+ score,
44
+ }
45
+ }
46
+
47
+ describe('assembleRAGContext', () => {
48
+ it('returns empty for empty input', () => {
49
+ expect(assembleRAGContext([])).toEqual({ content: '', sources: [], tokenCount: 0 })
50
+ })
51
+
52
+ it('joins non-empty chunks with the configured separator', () => {
53
+ const ctx = assembleRAGContext([result('alpha'), result('beta')], {
54
+ separator: ' | ',
55
+ maxTokens: 1000,
56
+ includeMetadata: false,
57
+ headerTemplate: undefined,
58
+ })
59
+ expect(ctx.content).toBe('alpha | beta')
60
+ })
61
+
62
+ it('includes a headerTemplate before chunks when provided', () => {
63
+ const ctx = assembleRAGContext([result('body')], {
64
+ separator: '\n',
65
+ maxTokens: 1000,
66
+ includeMetadata: false,
67
+ headerTemplate: '### Knowledge',
68
+ })
69
+ expect(ctx.content.startsWith('### Knowledge\n')).toBe(true)
70
+ })
71
+
72
+ it('early-breaks once a chunk would overflow maxTokens (and drops subsequent)', () => {
73
+ const long = 'a'.repeat(200) // ~50 tokens per the /4 estimate
74
+ const ctx = assembleRAGContext([result('tiny'), result(long), result('also-tiny')], {
75
+ separator: '\n',
76
+ maxTokens: 20,
77
+ includeMetadata: false,
78
+ headerTemplate: undefined,
79
+ })
80
+ expect(ctx.content).toBe('tiny')
81
+ // third chunk was NOT included even though it would fit — the loop breaks on first overflow.
82
+ expect(ctx.sources.map((s) => s.chunk.slice(0, 10))).toEqual(['tiny'])
83
+ })
84
+
85
+ it('includeMetadata prefixes entries with bracketed metadata', () => {
86
+ const ctx = assembleRAGContext([result('body', 0.7567, { source: 'repo', title: 'README' })], {
87
+ separator: '\n',
88
+ maxTokens: 1000,
89
+ includeMetadata: true,
90
+ })
91
+ expect(ctx.content).toContain('[Source: repo | Title: README | Relevance: 75.7%]')
92
+ })
93
+
94
+ it('sources[] carries the first 200 chars of each included chunk', () => {
95
+ const long = 'x'.repeat(500)
96
+ const ctx = assembleRAGContext([result(long)], {
97
+ separator: '\n',
98
+ maxTokens: 10000,
99
+ includeMetadata: false,
100
+ })
101
+ expect(ctx.sources[0]?.chunk).toHaveLength(200)
102
+ })
103
+
104
+ it('tokenCount is derived from the final joined content', () => {
105
+ const ctx = assembleRAGContext([result('abcd'), result('efgh')], {
106
+ separator: '',
107
+ maxTokens: 1000,
108
+ includeMetadata: false,
109
+ headerTemplate: undefined,
110
+ })
111
+ // 'abcdefgh' → Math.ceil(8 / 4) = 2
112
+ expect(ctx.tokenCount).toBe(2)
113
+ })
114
+ })
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Current-code invariants asserted (2026-04-21, ses_006 Phase 4):
3
+ *
4
+ * - `OpenRouterEmbeddingProvider`:
5
+ * - Defaults: dimensions = 1536; baseUrl = openrouter.ai/api/v1;
6
+ * batchSize = 64.
7
+ * - `embed(texts)` batches into `batchSize` slices and concatenates
8
+ * results in input order.
9
+ * - Each HTTP call posts `{model, input, dimensions}` to
10
+ * `${baseUrl}/embeddings` with the Bearer authorization header.
11
+ * - The API response is sorted by `index` ascending before extracting
12
+ * `.embedding`, so results match input order even if the server
13
+ * re-orders.
14
+ * - `embedQuery(query)` returns the first result from `embed([query])`;
15
+ * throws when the response is empty.
16
+ * - `!response.ok` → throws with `Embedding API error (<status>): <body>`.
17
+ */
18
+
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
20
+
21
+ import { OpenRouterEmbeddingProvider } from './embedding.js'
22
+
23
+ describe('OpenRouterEmbeddingProvider', () => {
24
+ let fetchMock: ReturnType<typeof vi.fn>
25
+
26
+ beforeEach(() => {
27
+ fetchMock = vi.fn()
28
+ global.fetch = fetchMock as unknown as typeof fetch
29
+ })
30
+
31
+ afterEach(() => {
32
+ vi.restoreAllMocks()
33
+ })
34
+
35
+ it('carries model / dimensions defaults + batchSize', () => {
36
+ const p = new OpenRouterEmbeddingProvider({ apiKey: 'k', model: 'm' })
37
+ expect(p.model).toBe('m')
38
+ expect(p.dimensions).toBe(1536)
39
+ })
40
+
41
+ it('honors overrides for dimensions + baseUrl + batchSize', async () => {
42
+ fetchMock.mockResolvedValue({
43
+ ok: true,
44
+ json: async () => ({ data: [{ index: 0, embedding: [1, 2, 3] }] }),
45
+ })
46
+ const p = new OpenRouterEmbeddingProvider({
47
+ apiKey: 'k',
48
+ model: 'm',
49
+ dimensions: 256,
50
+ baseUrl: 'https://custom.example/api',
51
+ })
52
+ await p.embed(['x'])
53
+
54
+ expect(fetchMock).toHaveBeenCalledWith(
55
+ 'https://custom.example/api/embeddings',
56
+ expect.objectContaining({
57
+ method: 'POST',
58
+ headers: expect.objectContaining({
59
+ Authorization: 'Bearer k',
60
+ 'Content-Type': 'application/json',
61
+ }),
62
+ }),
63
+ )
64
+ const body = JSON.parse((fetchMock.mock.calls[0]?.[1] as { body: string }).body)
65
+ expect(body).toEqual({ model: 'm', input: ['x'], dimensions: 256 })
66
+ expect(p.dimensions).toBe(256)
67
+ })
68
+
69
+ it('batches into batchSize slices', async () => {
70
+ fetchMock.mockResolvedValue({
71
+ ok: true,
72
+ json: async () => ({
73
+ data: [
74
+ { index: 0, embedding: [1] },
75
+ { index: 1, embedding: [2] },
76
+ ],
77
+ }),
78
+ })
79
+ const p = new OpenRouterEmbeddingProvider({
80
+ apiKey: 'k',
81
+ model: 'm',
82
+ batchSize: 2,
83
+ })
84
+ await p.embed(['a', 'b', 'c', 'd'])
85
+ expect(fetchMock).toHaveBeenCalledTimes(2)
86
+ })
87
+
88
+ it('sorts response by index before extracting embeddings', async () => {
89
+ fetchMock.mockResolvedValue({
90
+ ok: true,
91
+ json: async () => ({
92
+ data: [
93
+ { index: 2, embedding: [3] },
94
+ { index: 0, embedding: [1] },
95
+ { index: 1, embedding: [2] },
96
+ ],
97
+ }),
98
+ })
99
+ const p = new OpenRouterEmbeddingProvider({ apiKey: 'k', model: 'm' })
100
+ expect(await p.embed(['a', 'b', 'c'])).toEqual([[1], [2], [3]])
101
+ })
102
+
103
+ it('embedQuery returns the first result', async () => {
104
+ fetchMock.mockResolvedValue({
105
+ ok: true,
106
+ json: async () => ({ data: [{ index: 0, embedding: [9, 9] }] }),
107
+ })
108
+ const p = new OpenRouterEmbeddingProvider({ apiKey: 'k', model: 'm' })
109
+ expect(await p.embedQuery('hi')).toEqual([9, 9])
110
+ })
111
+
112
+ it('embedQuery throws when the response is empty', async () => {
113
+ fetchMock.mockResolvedValue({
114
+ ok: true,
115
+ json: async () => ({ data: [] }),
116
+ })
117
+ const p = new OpenRouterEmbeddingProvider({ apiKey: 'k', model: 'm' })
118
+ await expect(p.embedQuery('hi')).rejects.toThrow(/no results/)
119
+ })
120
+
121
+ it('throws on non-OK HTTP response', async () => {
122
+ fetchMock.mockResolvedValue({
123
+ ok: false,
124
+ status: 503,
125
+ text: async () => 'service unavailable',
126
+ })
127
+ const p = new OpenRouterEmbeddingProvider({ apiKey: 'k', model: 'm' })
128
+ await expect(p.embed(['hi'])).rejects.toThrow(/503.*service unavailable/)
129
+ })
130
+ })