@plumbus/core 0.2.9 → 0.3.1

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 (182) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/ai/__tests__/ai-service.test.js +704 -1
  3. package/dist/ai/__tests__/ai-service.test.js.map +1 -1
  4. package/dist/ai/__tests__/chunking.test.js +34 -0
  5. package/dist/ai/__tests__/chunking.test.js.map +1 -1
  6. package/dist/ai/__tests__/model-pricing.test.js +45 -1
  7. package/dist/ai/__tests__/model-pricing.test.js.map +1 -1
  8. package/dist/ai/__tests__/provider.test.d.ts.map +1 -1
  9. package/dist/ai/__tests__/provider.test.js +730 -1
  10. package/dist/ai/__tests__/provider.test.js.map +1 -1
  11. package/dist/ai/__tests__/validation.test.js +200 -1
  12. package/dist/ai/__tests__/validation.test.js.map +1 -1
  13. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts +2 -0
  14. package/dist/ai/__tests__/zod-to-provider-schema.test.d.ts.map +1 -0
  15. package/dist/ai/__tests__/zod-to-provider-schema.test.js +60 -0
  16. package/dist/ai/__tests__/zod-to-provider-schema.test.js.map +1 -0
  17. package/dist/ai/ai-service.d.ts +19 -2
  18. package/dist/ai/ai-service.d.ts.map +1 -1
  19. package/dist/ai/ai-service.js +544 -110
  20. package/dist/ai/ai-service.js.map +1 -1
  21. package/dist/ai/cost-tracker.d.ts +26 -1
  22. package/dist/ai/cost-tracker.d.ts.map +1 -1
  23. package/dist/ai/cost-tracker.js +1 -0
  24. package/dist/ai/cost-tracker.js.map +1 -1
  25. package/dist/ai/index.d.ts +6 -4
  26. package/dist/ai/index.d.ts.map +1 -1
  27. package/dist/ai/index.js +7 -3
  28. package/dist/ai/index.js.map +1 -1
  29. package/dist/ai/model-pricing.d.ts +24 -1
  30. package/dist/ai/model-pricing.d.ts.map +1 -1
  31. package/dist/ai/model-pricing.js +83 -56
  32. package/dist/ai/model-pricing.js.map +1 -1
  33. package/dist/ai/provider.d.ts +110 -1
  34. package/dist/ai/provider.d.ts.map +1 -1
  35. package/dist/ai/provider.js +358 -32
  36. package/dist/ai/provider.js.map +1 -1
  37. package/dist/ai/rag/chunking.js +53 -3
  38. package/dist/ai/rag/chunking.js.map +1 -1
  39. package/dist/ai/refusal.d.ts +28 -0
  40. package/dist/ai/refusal.d.ts.map +1 -0
  41. package/dist/ai/refusal.js +36 -0
  42. package/dist/ai/refusal.js.map +1 -0
  43. package/dist/ai/validation.d.ts +40 -8
  44. package/dist/ai/validation.d.ts.map +1 -1
  45. package/dist/ai/validation.js +97 -6
  46. package/dist/ai/validation.js.map +1 -1
  47. package/dist/ai/zod-to-provider-schema.d.ts +15 -0
  48. package/dist/ai/zod-to-provider-schema.d.ts.map +1 -0
  49. package/dist/ai/zod-to-provider-schema.js +200 -0
  50. package/dist/ai/zod-to-provider-schema.js.map +1 -0
  51. package/dist/cli/__tests__/doctor.test.js +104 -2
  52. package/dist/cli/__tests__/doctor.test.js.map +1 -1
  53. package/dist/cli/__tests__/init.test.js +127 -7
  54. package/dist/cli/__tests__/init.test.js.map +1 -1
  55. package/dist/cli/__tests__/migrate.test.js +159 -2
  56. package/dist/cli/__tests__/migrate.test.js.map +1 -1
  57. package/dist/cli/commands/__tests__/commands.test.js +5 -0
  58. package/dist/cli/commands/__tests__/commands.test.js.map +1 -1
  59. package/dist/cli/commands/dev.d.ts.map +1 -1
  60. package/dist/cli/commands/dev.js +21 -1
  61. package/dist/cli/commands/dev.js.map +1 -1
  62. package/dist/cli/commands/doctor.d.ts +2 -0
  63. package/dist/cli/commands/doctor.d.ts.map +1 -1
  64. package/dist/cli/commands/doctor.js +78 -0
  65. package/dist/cli/commands/doctor.js.map +1 -1
  66. package/dist/cli/commands/generate.d.ts.map +1 -1
  67. package/dist/cli/commands/generate.js +7 -2
  68. package/dist/cli/commands/generate.js.map +1 -1
  69. package/dist/cli/commands/init.d.ts +16 -2
  70. package/dist/cli/commands/init.d.ts.map +1 -1
  71. package/dist/cli/commands/init.js +226 -25
  72. package/dist/cli/commands/init.js.map +1 -1
  73. package/dist/cli/commands/migrate.d.ts.map +1 -1
  74. package/dist/cli/commands/migrate.js +258 -8
  75. package/dist/cli/commands/migrate.js.map +1 -1
  76. package/dist/cli/commands/start.d.ts.map +1 -1
  77. package/dist/cli/commands/start.js +21 -1
  78. package/dist/cli/commands/start.js.map +1 -1
  79. package/dist/data/__tests__/drift-inspector.test.d.ts +2 -0
  80. package/dist/data/__tests__/drift-inspector.test.d.ts.map +1 -0
  81. package/dist/data/__tests__/drift-inspector.test.js +170 -0
  82. package/dist/data/__tests__/drift-inspector.test.js.map +1 -0
  83. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts +2 -0
  84. package/dist/data/__tests__/framework-migration-rewriter.test.d.ts.map +1 -0
  85. package/dist/data/__tests__/framework-migration-rewriter.test.js +329 -0
  86. package/dist/data/__tests__/framework-migration-rewriter.test.js.map +1 -0
  87. package/dist/data/__tests__/migration.test.js +93 -2
  88. package/dist/data/__tests__/migration.test.js.map +1 -1
  89. package/dist/data/__tests__/repository.test.js +126 -0
  90. package/dist/data/__tests__/repository.test.js.map +1 -1
  91. package/dist/data/drift-inspector.d.ts +41 -0
  92. package/dist/data/drift-inspector.d.ts.map +1 -0
  93. package/dist/data/drift-inspector.js +197 -0
  94. package/dist/data/drift-inspector.js.map +1 -0
  95. package/dist/data/framework-migration-rewriter.d.ts +61 -0
  96. package/dist/data/framework-migration-rewriter.d.ts.map +1 -0
  97. package/dist/data/framework-migration-rewriter.js +357 -0
  98. package/dist/data/framework-migration-rewriter.js.map +1 -0
  99. package/dist/data/index.d.ts +2 -1
  100. package/dist/data/index.d.ts.map +1 -1
  101. package/dist/data/index.js +2 -1
  102. package/dist/data/index.js.map +1 -1
  103. package/dist/data/migration.d.ts +31 -1
  104. package/dist/data/migration.d.ts.map +1 -1
  105. package/dist/data/migration.js +113 -3
  106. package/dist/data/migration.js.map +1 -1
  107. package/dist/data/repository.d.ts.map +1 -1
  108. package/dist/data/repository.js +21 -0
  109. package/dist/data/repository.js.map +1 -1
  110. package/dist/define/__tests__/definePrompt.test.js +37 -0
  111. package/dist/define/__tests__/definePrompt.test.js.map +1 -1
  112. package/dist/define/definePrompt.d.ts +13 -0
  113. package/dist/define/definePrompt.d.ts.map +1 -1
  114. package/dist/define/definePrompt.js +3 -0
  115. package/dist/define/definePrompt.js.map +1 -1
  116. package/dist/errors/http.d.ts.map +1 -1
  117. package/dist/errors/http.js +2 -0
  118. package/dist/errors/http.js.map +1 -1
  119. package/dist/errors/index.d.ts +12 -0
  120. package/dist/errors/index.d.ts.map +1 -1
  121. package/dist/errors/index.js +21 -0
  122. package/dist/errors/index.js.map +1 -1
  123. package/dist/events/__tests__/event-emitter.test.js +72 -0
  124. package/dist/events/__tests__/event-emitter.test.js.map +1 -1
  125. package/dist/events/emitter.d.ts.map +1 -1
  126. package/dist/events/emitter.js +51 -0
  127. package/dist/events/emitter.js.map +1 -1
  128. package/dist/execution/__tests__/context-factory.test.js +1 -0
  129. package/dist/execution/__tests__/context-factory.test.js.map +1 -1
  130. package/dist/execution/context-factory.d.ts.map +1 -1
  131. package/dist/execution/context-factory.js +2 -0
  132. package/dist/execution/context-factory.js.map +1 -1
  133. package/dist/flows/__tests__/flow-engine.test.js +451 -4
  134. package/dist/flows/__tests__/flow-engine.test.js.map +1 -1
  135. package/dist/flows/__tests__/flow-service.test.js +8 -0
  136. package/dist/flows/__tests__/flow-service.test.js.map +1 -1
  137. package/dist/flows/__tests__/step-executor.test.js +8 -2
  138. package/dist/flows/__tests__/step-executor.test.js.map +1 -1
  139. package/dist/flows/engine.d.ts +50 -1
  140. package/dist/flows/engine.d.ts.map +1 -1
  141. package/dist/flows/engine.js +427 -23
  142. package/dist/flows/engine.js.map +1 -1
  143. package/dist/flows/flow-service.d.ts.map +1 -1
  144. package/dist/flows/flow-service.js +4 -0
  145. package/dist/flows/flow-service.js.map +1 -1
  146. package/dist/flows/index.d.ts +1 -1
  147. package/dist/flows/index.d.ts.map +1 -1
  148. package/dist/flows/index.js +1 -1
  149. package/dist/flows/index.js.map +1 -1
  150. package/dist/flows/schema.d.ts +34 -0
  151. package/dist/flows/schema.d.ts.map +1 -1
  152. package/dist/flows/schema.js +3 -0
  153. package/dist/flows/schema.js.map +1 -1
  154. package/dist/index.d.ts +4 -4
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +3 -3
  157. package/dist/index.js.map +1 -1
  158. package/dist/server/bootstrap.d.ts +14 -0
  159. package/dist/server/bootstrap.d.ts.map +1 -1
  160. package/dist/server/bootstrap.js +23 -4
  161. package/dist/server/bootstrap.js.map +1 -1
  162. package/dist/testing/context.d.ts.map +1 -1
  163. package/dist/testing/context.js +37 -1
  164. package/dist/testing/context.js.map +1 -1
  165. package/dist/types/context.d.ts +110 -0
  166. package/dist/types/context.d.ts.map +1 -1
  167. package/dist/types/enums.d.ts +2 -0
  168. package/dist/types/enums.d.ts.map +1 -1
  169. package/dist/types/enums.js +2 -0
  170. package/dist/types/enums.js.map +1 -1
  171. package/dist/types/prompt.d.ts +64 -0
  172. package/dist/types/prompt.d.ts.map +1 -1
  173. package/dist/worker/__tests__/bootstrap.test.js +36 -2
  174. package/dist/worker/__tests__/bootstrap.test.js.map +1 -1
  175. package/dist/worker/bootstrap.d.ts +7 -0
  176. package/dist/worker/bootstrap.d.ts.map +1 -1
  177. package/dist/worker/bootstrap.js +153 -12
  178. package/dist/worker/bootstrap.js.map +1 -1
  179. package/instructions/framework.md +7 -0
  180. package/instructions/guardrails.md +39 -0
  181. package/instructions/patterns.md +10 -0
  182. package/package.json +3 -2
@@ -6,6 +6,7 @@ import { createCostTracker } from '../cost-tracker.js';
6
6
  import { createExplainabilityTracker } from '../explainability.js';
7
7
  import { PromptRegistry } from '../prompt-registry.js';
8
8
  import { createInMemoryVectorStore, createRAGPipeline } from '../rag/pipeline.js';
9
+ import { AIIncompleteOutputError, AIRefusalError } from '../refusal.js';
9
10
  import { createMockProvider } from './provider.test.js';
10
11
  describe('AI Service (ctx.ai)', () => {
11
12
  function setupService(opts) {
@@ -35,6 +36,7 @@ describe('AI Service (ctx.ai)', () => {
35
36
  costTracker,
36
37
  ragPipeline,
37
38
  defaultModel: 'mock-model',
39
+ enableStrictStructuredOutputs: opts?.strictStructuredOutputs,
38
40
  budget: { tenantId: 't1', actor: 'user1' },
39
41
  }));
40
42
  return { service, provider, promptRegistry, costTracker, explainability };
@@ -54,7 +56,7 @@ describe('AI Service (ctx.ai)', () => {
54
56
  name: 'greet',
55
57
  description: 'Say hello to {{name}}',
56
58
  input: z.object({ name: z.string() }),
57
- output: z.object({ result: z.string() }),
59
+ output: z.object({ result: z.string(), language: z.string().optional() }),
58
60
  }));
59
61
  const result = await service.generate({
60
62
  prompt: 'greet',
@@ -63,12 +65,288 @@ describe('AI Service (ctx.ai)', () => {
63
65
  expect(result).toEqual({ result: 'hello' });
64
66
  expect(provider.complete).toHaveBeenCalled();
65
67
  });
68
+ it('renders registered system prompts without duplicating substituted input', async () => {
69
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
70
+ promptRegistry?.register(definePrompt({
71
+ name: 'system-greet',
72
+ system: 'You are {{role}}.',
73
+ description: 'Data: {{text}}',
74
+ input: z.object({
75
+ role: z.string(),
76
+ text: z.string(),
77
+ locale: z.string(),
78
+ }),
79
+ output: z.object({ result: z.string(), language: z.string().optional() }),
80
+ }));
81
+ await service.generate({
82
+ prompt: 'system-greet',
83
+ input: { role: 'editor', text: 'hello', locale: 'en' },
84
+ });
85
+ expect(provider.complete).toHaveBeenCalledWith(expect.objectContaining({
86
+ system: 'You are editor.',
87
+ prompt: expect.stringContaining('Data: hello\n\nInput: {"locale":"en"}'),
88
+ }));
89
+ });
90
+ it('keeps system undefined for registered prompts without a system template', async () => {
91
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
92
+ promptRegistry?.register(definePrompt({
93
+ name: 'no-system',
94
+ description: 'Data: {{text}}',
95
+ input: z.object({ text: z.string() }),
96
+ output: z.object({ result: z.string(), language: z.string().optional() }),
97
+ }));
98
+ await service.generate({
99
+ prompt: 'no-system',
100
+ input: { text: 'hello' },
101
+ });
102
+ expect(provider.complete).toHaveBeenCalledWith(expect.objectContaining({
103
+ system: undefined,
104
+ prompt: expect.stringContaining('Data: hello'),
105
+ }));
106
+ });
107
+ it('can omit unsubstituted input when a prompt renders the complete user message', async () => {
108
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
109
+ promptRegistry?.register(definePrompt({
110
+ name: 'complete-user-message',
111
+ system: '{{systemInstructions}}',
112
+ description: '{{userPrompt}}',
113
+ input: z.object({
114
+ systemInstructions: z.string(),
115
+ userPrompt: z.string(),
116
+ sourceText: z.string(),
117
+ }),
118
+ output: z.object({ result: z.string(), language: z.string().optional() }),
119
+ appendUnsubstitutedInput: false,
120
+ }));
121
+ await service.generate({
122
+ prompt: 'complete-user-message',
123
+ input: {
124
+ systemInstructions: 'System text',
125
+ userPrompt: 'User text',
126
+ sourceText: 'raw source',
127
+ },
128
+ });
129
+ expect(provider.complete).toHaveBeenCalledWith(expect.objectContaining({
130
+ system: 'System text',
131
+ prompt: expect.stringContaining('User text'),
132
+ }));
133
+ });
134
+ it('passes a strict provider schema for registered JSON prompts when enabled', async () => {
135
+ const { service, promptRegistry, provider } = setupService({
136
+ promptRegistry: true,
137
+ strictStructuredOutputs: true,
138
+ });
139
+ promptRegistry?.register(definePrompt({
140
+ name: 'strict-greet',
141
+ description: 'Say hello to {{name}} as JSON.',
142
+ input: z.object({ name: z.string() }),
143
+ output: z.object({ result: z.string(), language: z.string().optional() }),
144
+ }));
145
+ await service.generate({
146
+ prompt: 'strict-greet',
147
+ input: { name: 'Alice' },
148
+ });
149
+ expect(provider.complete).toHaveBeenCalledWith(expect.objectContaining({
150
+ responseFormat: 'json',
151
+ responseSchema: expect.objectContaining({
152
+ type: 'object',
153
+ additionalProperties: false,
154
+ }),
155
+ }));
156
+ });
157
+ it('does not add a prose JSON hint when provider schema is active', async () => {
158
+ const { service, promptRegistry, provider } = setupService({
159
+ promptRegistry: true,
160
+ strictStructuredOutputs: true,
161
+ });
162
+ promptRegistry?.register(definePrompt({
163
+ name: 'schema-only-extract',
164
+ description: 'Extract names from {{text}}.',
165
+ input: z.object({ text: z.string() }),
166
+ output: z.object({ result: z.string(), language: z.string().optional() }),
167
+ requireStrictStructuredOutputs: true,
168
+ }));
169
+ await service.generate({
170
+ prompt: 'schema-only-extract',
171
+ input: { text: 'Alice and Bob' },
172
+ });
173
+ expect(provider.complete).toHaveBeenCalledWith(expect.objectContaining({
174
+ responseFormat: 'json',
175
+ responseSchema: expect.objectContaining({ type: 'object' }),
176
+ prompt: expect.not.stringContaining('Respond with a valid JSON object'),
177
+ }));
178
+ });
179
+ it('fails required strict structured prompts when provider schema is disabled', async () => {
180
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
181
+ promptRegistry?.register(definePrompt({
182
+ name: 'strict-required',
183
+ description: 'Extract names from {{text}}.',
184
+ input: z.object({ text: z.string() }),
185
+ output: z.object({ names: z.array(z.string()) }),
186
+ requireStrictStructuredOutputs: true,
187
+ }));
188
+ await expect(service.generate({
189
+ prompt: 'strict-required',
190
+ input: { text: 'Alice and Bob' },
191
+ })).rejects.toThrow('requires provider-side structured outputs');
192
+ expect(provider.complete).not.toHaveBeenCalled();
193
+ });
194
+ it('does not pass a strict provider schema for prompt-level opt outs', async () => {
195
+ const { service, promptRegistry, provider } = setupService({
196
+ promptRegistry: true,
197
+ strictStructuredOutputs: true,
198
+ });
199
+ promptRegistry?.register(definePrompt({
200
+ name: 'legacy-json',
201
+ description: 'Say hello as JSON.',
202
+ input: z.object({}),
203
+ output: z.object({ result: z.string() }),
204
+ disableStrictStructuredOutputs: true,
205
+ }));
206
+ await service.generate({ prompt: 'legacy-json', input: {} });
207
+ expect(provider.complete).toHaveBeenCalledWith(expect.objectContaining({
208
+ responseFormat: 'json',
209
+ responseSchema: undefined,
210
+ }));
211
+ });
66
212
  it('tracks cost when cost tracker is present', async () => {
67
213
  const { service, costTracker } = setupService({ costTracker: true });
68
214
  await service.generate({ prompt: 'test', input: {} });
69
215
  expect(costTracker?.getRecords()).toHaveLength(1);
70
216
  expect(costTracker?.getRecords()[0]?.operation).toBe('generate');
71
217
  });
218
+ it('records refused status and usage for provider refusals', async () => {
219
+ const provider = createMockProvider({
220
+ complete: vi.fn(async () => {
221
+ throw new AIRefusalError({
222
+ provider: 'mock',
223
+ model: 'mock-model',
224
+ refusalText: 'No.',
225
+ usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 },
226
+ });
227
+ }),
228
+ });
229
+ const promptRegistry = new PromptRegistry();
230
+ promptRegistry.register(definePrompt({
231
+ name: 'refused',
232
+ description: 'Return JSON.',
233
+ input: z.object({}),
234
+ output: z.object({ result: z.string() }),
235
+ }));
236
+ const costTracker = createCostTracker();
237
+ const service = createAIService(singleProviderConfig(provider, {
238
+ promptRegistry,
239
+ costTracker,
240
+ defaultModel: 'mock-model',
241
+ enableStrictStructuredOutputs: true,
242
+ }));
243
+ await expect(service.generate({ prompt: 'refused', input: {} })).rejects.toThrow(AIRefusalError);
244
+ expect(costTracker.getRecords()[0]).toMatchObject({
245
+ status: 'refused',
246
+ usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 },
247
+ });
248
+ });
249
+ it('records incomplete status and usage for truncated structured outputs', async () => {
250
+ const provider = createMockProvider({
251
+ complete: vi.fn(async () => {
252
+ throw new AIIncompleteOutputError({
253
+ provider: 'mock',
254
+ model: 'mock-model',
255
+ partialText: '{"result":',
256
+ usage: { inputTokens: 3, outputTokens: 8, totalTokens: 11 },
257
+ finishReason: 'length',
258
+ });
259
+ }),
260
+ });
261
+ const promptRegistry = new PromptRegistry();
262
+ promptRegistry.register(definePrompt({
263
+ name: 'truncated',
264
+ description: 'Return JSON.',
265
+ input: z.object({}),
266
+ output: z.object({ result: z.string() }),
267
+ }));
268
+ const costTracker = createCostTracker();
269
+ const service = createAIService(singleProviderConfig(provider, {
270
+ promptRegistry,
271
+ costTracker,
272
+ defaultModel: 'mock-model',
273
+ enableStrictStructuredOutputs: true,
274
+ }));
275
+ await expect(service.generate({ prompt: 'truncated', input: {} })).rejects.toThrow(AIIncompleteOutputError);
276
+ expect(costTracker.getRecords()[0]).toMatchObject({
277
+ status: 'incomplete',
278
+ usage: { inputTokens: 3, outputTokens: 8, totalTokens: 11 },
279
+ });
280
+ });
281
+ });
282
+ describe('prompt assembly', () => {
283
+ function getRequestPrompt(provider) {
284
+ const mock = provider.complete;
285
+ const firstCall = mock.mock.calls[0];
286
+ const req = firstCall?.[0];
287
+ return req?.prompt ?? '';
288
+ }
289
+ it('does NOT append `Input: {...}` when every input key is consumed by a {{placeholder}}', async () => {
290
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
291
+ promptRegistry?.register(definePrompt({
292
+ name: 'all-substituted',
293
+ description: 'Hello {{name}}, you are {{age}} years old. Respond as JSON.',
294
+ input: z.object({ name: z.string(), age: z.number() }),
295
+ output: z.object({ result: z.string() }),
296
+ }));
297
+ await service.generate({
298
+ prompt: 'all-substituted',
299
+ input: { name: 'Alice', age: 30 },
300
+ });
301
+ const sent = getRequestPrompt(provider);
302
+ expect(sent).toContain('Hello Alice, you are 30 years old');
303
+ expect(sent).not.toContain('Input: {');
304
+ // Each substituted value should appear exactly once (no duplication)
305
+ expect(sent.match(/Alice/g)?.length ?? 0).toBe(1);
306
+ expect(sent.match(/\b30\b/g)?.length ?? 0).toBe(1);
307
+ });
308
+ it('appends only the unsubstituted keys when input has extras beyond the placeholders', async () => {
309
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
310
+ promptRegistry?.register(definePrompt({
311
+ name: 'partial-substituted',
312
+ description: 'Greet {{name}}. Respond as JSON.',
313
+ input: z.object({ name: z.string(), context: z.string() }),
314
+ output: z.object({ result: z.string() }),
315
+ }));
316
+ await service.generate({
317
+ prompt: 'partial-substituted',
318
+ input: { name: 'Alice', context: 'morning meeting' },
319
+ });
320
+ const sent = getRequestPrompt(provider);
321
+ expect(sent).toContain('Greet Alice');
322
+ expect(sent).toContain('Input: {');
323
+ expect(sent).toContain('"context":"morning meeting"');
324
+ expect(sent).not.toContain('"name":"Alice"');
325
+ // Substituted value still appears only once (in the rendered template, not in Input blob)
326
+ expect(sent.match(/Alice/g)?.length ?? 0).toBe(1);
327
+ });
328
+ it('appends Input: {...} when prompt has no placeholders for any input key', async () => {
329
+ const { service, promptRegistry, provider } = setupService({ promptRegistry: true });
330
+ promptRegistry?.register(definePrompt({
331
+ name: 'no-placeholders',
332
+ description: 'Process the input. Respond as JSON.',
333
+ input: z.object({ name: z.string() }),
334
+ output: z.object({ result: z.string() }),
335
+ }));
336
+ await service.generate({
337
+ prompt: 'no-placeholders',
338
+ input: { name: 'Alice' },
339
+ });
340
+ const sent = getRequestPrompt(provider);
341
+ expect(sent).toContain('Process the input.');
342
+ expect(sent).toContain('Input: {"name":"Alice"}');
343
+ });
344
+ it('raw-prompt path (no registry def) is unchanged: no Input append, prompt sent as-is', async () => {
345
+ const { service, provider } = setupService();
346
+ await service.generate({ prompt: 'just say hi', input: {} });
347
+ const sent = getRequestPrompt(provider);
348
+ expect(sent).toBe('just say hi');
349
+ });
72
350
  });
73
351
  describe('generateWithUsage', () => {
74
352
  it('returns data and usage for raw prompt', async () => {
@@ -96,6 +374,45 @@ describe('AI Service (ctx.ai)', () => {
96
374
  expect(result.usage.inputTokens).toBe(10);
97
375
  expect(result.usage.outputTokens).toBe(20);
98
376
  });
377
+ it('allows per-request validation overrides', async () => {
378
+ let callCount = 0;
379
+ const provider = createMockProvider({
380
+ complete: vi.fn(async () => {
381
+ callCount += 1;
382
+ if (callCount === 1) {
383
+ return {
384
+ content: '{"wrong":"schema"}',
385
+ model: 'mock-model',
386
+ usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
387
+ finishReason: 'stop',
388
+ };
389
+ }
390
+ return {
391
+ content: '{"result":"hello"}',
392
+ model: 'mock-model',
393
+ usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
394
+ finishReason: 'stop',
395
+ };
396
+ }),
397
+ });
398
+ const promptRegistry = new PromptRegistry();
399
+ promptRegistry.register(definePrompt({
400
+ name: 'greet-once',
401
+ description: 'Say hello once',
402
+ input: z.object({}),
403
+ output: z.object({ result: z.string() }),
404
+ }));
405
+ const service = createAIService(singleProviderConfig(provider, {
406
+ promptRegistry,
407
+ defaultModel: 'mock-model',
408
+ }));
409
+ await expect(service.generateWithUsage({
410
+ prompt: 'greet-once',
411
+ input: {},
412
+ validation: { maxRetries: 0, feedbackOnError: false },
413
+ })).rejects.toThrow('AI output validation failed after 1 attempts');
414
+ expect(provider.complete).toHaveBeenCalledTimes(1);
415
+ });
99
416
  });
100
417
  describe('extract', () => {
101
418
  it('extracts structured data from text', async () => {
@@ -370,5 +687,391 @@ describe('AI Service (ctx.ai)', () => {
370
687
  expect(call?.model).toBe('gpt-4o');
371
688
  });
372
689
  });
690
+ // ── Failure-path cost recording + onAICostRecorded hook ──
691
+ describe('failure-path cost recording', () => {
692
+ function makePromptRegistry() {
693
+ const reg = new PromptRegistry();
694
+ reg.register(definePrompt({
695
+ name: 'greet',
696
+ description: 'Say hello to {{name}}',
697
+ input: z.object({ name: z.string() }),
698
+ output: z.object({ result: z.string() }),
699
+ }));
700
+ return reg;
701
+ }
702
+ it('records status=success on the happy path with onAICostRecorded', async () => {
703
+ const provider = createMockProvider({
704
+ complete: vi.fn(async () => ({
705
+ content: '{"result":"hi"}',
706
+ model: 'mock',
707
+ usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
708
+ finishReason: 'stop',
709
+ })),
710
+ });
711
+ const costTracker = createCostTracker();
712
+ const hookCalls = [];
713
+ const service = createAIService(singleProviderConfig(provider, {
714
+ promptRegistry: makePromptRegistry(),
715
+ costTracker,
716
+ defaultModel: 'mock-model',
717
+ onAICostRecorded: (record, ctx) => {
718
+ hookCalls.push({ record, ctx });
719
+ },
720
+ }));
721
+ await service.generate({
722
+ prompt: 'greet',
723
+ input: { name: 'Alice' },
724
+ costContext: { projectId: 'p1', operationName: 'test' },
725
+ });
726
+ expect(costTracker.getRecords()).toHaveLength(1);
727
+ expect(costTracker.getRecords()[0]?.status).toBe('success');
728
+ expect(hookCalls).toHaveLength(1);
729
+ expect(hookCalls[0]?.record.status).toBe('success');
730
+ expect(hookCalls[0]?.ctx).toEqual({ projectId: 'p1', operationName: 'test' });
731
+ });
732
+ it('records status=failed with AIValidationError.usage when validation exhausts retries', async () => {
733
+ const provider = createMockProvider({
734
+ complete: vi.fn(async () => ({
735
+ content: '{"wrong":"schema"}',
736
+ model: 'mock',
737
+ usage: { inputTokens: 10, outputTokens: 10, totalTokens: 20 },
738
+ finishReason: 'stop',
739
+ })),
740
+ });
741
+ const costTracker = createCostTracker();
742
+ const hookCalls = [];
743
+ const service = createAIService(singleProviderConfig(provider, {
744
+ promptRegistry: makePromptRegistry(),
745
+ costTracker,
746
+ defaultModel: 'mock-model',
747
+ onAICostRecorded: (record, ctx) => {
748
+ hookCalls.push({ record, ctx });
749
+ },
750
+ }));
751
+ await expect(service.generate({
752
+ prompt: 'greet',
753
+ input: { name: 'Alice' },
754
+ validation: { maxRetries: 1, feedbackOnError: false },
755
+ costContext: { projectId: 'p2' },
756
+ })).rejects.toThrow(/AI output validation failed/);
757
+ expect(costTracker.getRecords()).toHaveLength(1);
758
+ const record = costTracker.getRecords()[0];
759
+ expect(record?.status).toBe('failed');
760
+ // Two attempts × 20 tokens each = 40 accumulated via AIValidationError.usage
761
+ expect(record?.usage.totalTokens).toBe(40);
762
+ expect(record?.errorMessage).toMatch(/validation/i);
763
+ expect(hookCalls).toHaveLength(1);
764
+ expect(hookCalls[0]?.record.status).toBe('failed');
765
+ expect(hookCalls[0]?.ctx).toEqual({ projectId: 'p2' });
766
+ });
767
+ it('records status=failed with zero usage when provider throws an opaque error', async () => {
768
+ const provider = createMockProvider({
769
+ complete: vi.fn(async () => {
770
+ throw new Error('Headers Timeout Error');
771
+ }),
772
+ });
773
+ const costTracker = createCostTracker();
774
+ const service = createAIService(singleProviderConfig(provider, {
775
+ costTracker,
776
+ defaultModel: 'mock-model',
777
+ }));
778
+ await expect(service.generate({ prompt: 'just a raw prompt', input: {} })).rejects.toThrow('Headers Timeout Error');
779
+ const records = costTracker.getRecords();
780
+ expect(records).toHaveLength(1);
781
+ expect(records[0]?.status).toBe('failed');
782
+ expect(records[0]?.usage.totalTokens).toBe(0);
783
+ expect(records[0]?.errorMessage).toContain('Headers Timeout Error');
784
+ });
785
+ it('streamGenerate records status=failed on mid-stream transport error', async () => {
786
+ const provider = {
787
+ name: 'mock',
788
+ complete: vi.fn(),
789
+ async *stream() {
790
+ yield { type: 'content_delta', delta: '' };
791
+ throw new Error('socket hang up');
792
+ },
793
+ embed: vi.fn(),
794
+ };
795
+ const costTracker = createCostTracker();
796
+ const service = createAIService(singleProviderConfig(provider, {
797
+ costTracker,
798
+ defaultModel: 'mock-model',
799
+ }));
800
+ const iterator = service.streamGenerate({ prompt: 'test', input: {} });
801
+ await expect(async () => {
802
+ for await (const _ of iterator) {
803
+ // drain
804
+ }
805
+ }).rejects.toThrow('socket hang up');
806
+ const records = costTracker.getRecords();
807
+ expect(records).toHaveLength(1);
808
+ expect(records[0]?.status).toBe('failed');
809
+ expect(records[0]?.errorMessage).toContain('socket hang up');
810
+ });
811
+ it('streamGenerate records status=failed with combined stream+fallback usage on fallback validation failure', async () => {
812
+ const reg = new PromptRegistry();
813
+ reg.register(definePrompt({
814
+ name: 'structured',
815
+ description: 'Return json',
816
+ input: z.object({}),
817
+ output: z.object({ ok: z.boolean(), name: z.string() }),
818
+ }));
819
+ const stream = vi.fn().mockImplementation(async function* () {
820
+ // Emit malformed JSON so local parse fails and we fall back
821
+ yield { type: 'content_delta', delta: '{"ok":' };
822
+ yield {
823
+ type: 'usage',
824
+ usage: { inputTokens: 100, outputTokens: 5, totalTokens: 105 },
825
+ };
826
+ yield { type: 'done', finishReason: 'stop' };
827
+ });
828
+ const complete = vi.fn(async () => ({
829
+ content: '{"still":"wrong"}',
830
+ model: 'mock',
831
+ usage: { inputTokens: 50, outputTokens: 5, totalTokens: 55 },
832
+ finishReason: 'stop',
833
+ }));
834
+ const provider = {
835
+ name: 'mock',
836
+ complete,
837
+ stream,
838
+ embed: vi.fn(),
839
+ };
840
+ const costTracker = createCostTracker();
841
+ const service = createAIService(singleProviderConfig(provider, {
842
+ promptRegistry: reg,
843
+ costTracker,
844
+ defaultModel: 'mock-model',
845
+ validation: { maxRetries: 0, feedbackOnError: false },
846
+ }));
847
+ const iterator = service.streamGenerate({ prompt: 'structured', input: {} });
848
+ await expect(async () => {
849
+ for await (const _ of iterator) {
850
+ // drain
851
+ }
852
+ }).rejects.toThrow(/AI output validation failed/);
853
+ const records = costTracker.getRecords();
854
+ expect(records).toHaveLength(1);
855
+ expect(records[0]?.status).toBe('failed');
856
+ // streamUsage (105) + fallback validation usage (55) = 160 total
857
+ expect(records[0]?.usage.totalTokens).toBe(160);
858
+ expect(records[0]?.usage.inputTokens).toBe(150);
859
+ expect(records[0]?.usage.outputTokens).toBe(10);
860
+ });
861
+ it('onAICostRecorded hook errors are swallowed and do not break the AI call', async () => {
862
+ const provider = createMockProvider({
863
+ complete: vi.fn(async () => ({
864
+ content: '{"result":"ok"}',
865
+ model: 'mock',
866
+ usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 },
867
+ finishReason: 'stop',
868
+ })),
869
+ });
870
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
871
+ const service = createAIService(singleProviderConfig(provider, {
872
+ promptRegistry: makePromptRegistry(),
873
+ defaultModel: 'mock-model',
874
+ onAICostRecorded: () => {
875
+ throw new Error('hook boom');
876
+ },
877
+ }));
878
+ const result = await service.generate({ prompt: 'greet', input: { name: 'Alice' } });
879
+ expect(result).toEqual({ result: 'ok' });
880
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('hook boom'));
881
+ consoleSpy.mockRestore();
882
+ });
883
+ });
884
+ // ── Text-mode brevity-hint suffix (and its opt-out) ──
885
+ // The ai-service auto-detects single-string-field output schemas
886
+ // (e.g. z.object({ content: z.string() })) and switches to plain text
887
+ // mode while appending a brevity-hint suffix that tells the model to
888
+ // "Respond with ONLY the plain text content...". That hint is the right
889
+ // default for short Q&A but collapses long-form prose into bullet points
890
+ // on some models — so prompts can opt out via
891
+ // `disableTextModeBrevityHint: true`.
892
+ describe('text-mode brevity hint', () => {
893
+ function makeStreamSpy() {
894
+ return vi.fn().mockImplementation(async function* () {
895
+ yield { type: 'content_delta', delta: 'short body' };
896
+ yield {
897
+ type: 'usage',
898
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
899
+ };
900
+ yield { type: 'done', finishReason: 'stop' };
901
+ });
902
+ }
903
+ function getStreamRequest(streamSpy) {
904
+ const firstCall = streamSpy.mock.calls[0];
905
+ const req = firstCall?.[0];
906
+ return req ?? {};
907
+ }
908
+ async function drain(iter) {
909
+ for await (const _ of iter) {
910
+ // drain
911
+ }
912
+ }
913
+ it('default text-mode (single string field, no opt-out): appends brevity-hint suffix', async () => {
914
+ const streamSpy = makeStreamSpy();
915
+ const provider = {
916
+ name: 'mock',
917
+ complete: vi.fn(),
918
+ stream: streamSpy,
919
+ embed: vi.fn(),
920
+ };
921
+ const reg = new PromptRegistry();
922
+ reg.register(definePrompt({
923
+ name: 'qa',
924
+ description: 'Answer the question.',
925
+ input: z.object({}),
926
+ output: z.object({ content: z.string() }),
927
+ }));
928
+ const service = createAIService(singleProviderConfig(provider, { promptRegistry: reg, defaultModel: 'mock-model' }));
929
+ await drain(service.streamGenerate({ prompt: 'qa', input: {} }));
930
+ const req = getStreamRequest(streamSpy);
931
+ expect(req.prompt ?? '').toContain('Respond with ONLY the plain text content');
932
+ expect(req.responseFormat).toBe('text');
933
+ });
934
+ it('forwards registered system prompts on streaming calls', async () => {
935
+ const streamSpy = makeStreamSpy();
936
+ const provider = {
937
+ name: 'mock',
938
+ complete: vi.fn(),
939
+ stream: streamSpy,
940
+ embed: vi.fn(),
941
+ };
942
+ const reg = new PromptRegistry();
943
+ reg.register(definePrompt({
944
+ name: 'stream-system',
945
+ system: 'You are {{role}}.',
946
+ description: 'Write about {{topic}}',
947
+ input: z.object({
948
+ role: z.string(),
949
+ topic: z.string(),
950
+ locale: z.string(),
951
+ }),
952
+ output: z.object({ content: z.string() }),
953
+ disableTextModeBrevityHint: true,
954
+ }));
955
+ const service = createAIService(singleProviderConfig(provider, { promptRegistry: reg, defaultModel: 'mock-model' }));
956
+ await drain(service.streamGenerate({
957
+ prompt: 'stream-system',
958
+ input: { role: 'editor', topic: 'Baghdad', locale: 'he' },
959
+ }));
960
+ const req = getStreamRequest(streamSpy);
961
+ expect(req.system).toBe('You are editor.');
962
+ expect(req.prompt).toBe('Write about Baghdad\n\nInput: {"locale":"he"}');
963
+ expect(req.responseFormat).toBe('text');
964
+ });
965
+ it('text-mode with disableTextModeBrevityHint=true: omits brevity-hint suffix but stays in text mode', async () => {
966
+ const streamSpy = makeStreamSpy();
967
+ const provider = {
968
+ name: 'mock',
969
+ complete: vi.fn(),
970
+ stream: streamSpy,
971
+ embed: vi.fn(),
972
+ };
973
+ const reg = new PromptRegistry();
974
+ reg.register(definePrompt({
975
+ name: 'long-form',
976
+ description: 'Write a long chapter. Output the title on the first line as a markdown H1.',
977
+ input: z.object({}),
978
+ output: z.object({ content: z.string() }),
979
+ disableTextModeBrevityHint: true,
980
+ }));
981
+ const service = createAIService(singleProviderConfig(provider, { promptRegistry: reg, defaultModel: 'mock-model' }));
982
+ await drain(service.streamGenerate({ prompt: 'long-form', input: {} }));
983
+ const req = getStreamRequest(streamSpy);
984
+ const sent = req.prompt ?? '';
985
+ expect(sent).not.toContain('Respond with ONLY the plain text content');
986
+ expect(sent).not.toContain('Do NOT wrap your response in JSON');
987
+ // Body must still be there — only the appended suffix is suppressed
988
+ expect(sent).toContain('Write a long chapter');
989
+ // And we are still in text mode (the whole point of the opt-out is to
990
+ // keep the streamed payload as raw text so JSON-escape rules never
991
+ // apply — e.g. for Hebrew content with embedded gershayim).
992
+ expect(req.responseFormat).toBe('text');
993
+ });
994
+ it('json-mode (multi-field schema) is unaffected by the opt-out flag: JSON instruction still appended', async () => {
995
+ const streamSpy = vi.fn().mockImplementation(async function* () {
996
+ yield { type: 'content_delta', delta: '{"a":"x","b":"y"}' };
997
+ yield {
998
+ type: 'usage',
999
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
1000
+ };
1001
+ yield { type: 'done', finishReason: 'stop' };
1002
+ });
1003
+ const provider = {
1004
+ name: 'mock',
1005
+ complete: vi.fn(),
1006
+ stream: streamSpy,
1007
+ embed: vi.fn(),
1008
+ };
1009
+ const reg = new PromptRegistry();
1010
+ reg.register(definePrompt({
1011
+ name: 'multi',
1012
+ description: 'Return two fields.',
1013
+ input: z.object({}),
1014
+ output: z.object({ a: z.string(), b: z.string() }),
1015
+ // Even with the flag set, multi-field schemas are not text-mode
1016
+ // candidates, so this flag is a no-op for them.
1017
+ disableTextModeBrevityHint: true,
1018
+ }));
1019
+ const service = createAIService(singleProviderConfig(provider, { promptRegistry: reg, defaultModel: 'mock-model' }));
1020
+ await drain(service.streamGenerate({ prompt: 'multi', input: {} }));
1021
+ const req = getStreamRequest(streamSpy);
1022
+ expect(req.responseFormat).toBe('json');
1023
+ expect(req.prompt ?? '').toContain('Respond with a valid JSON object');
1024
+ // And the text-mode hint is definitely NOT in a JSON-mode call.
1025
+ expect(req.prompt ?? '').not.toContain('Respond with ONLY the plain text content');
1026
+ });
1027
+ });
1028
+ // ── Backward-compat: record() input type keeps `status` optional ──
1029
+ describe('CostTracker backward-compat', () => {
1030
+ it('record(entry) without status still compiles and defaults to success', () => {
1031
+ // This test's real value is type-level: if `status` stops being
1032
+ // optional on AICostRecordInput, this file fails to compile and
1033
+ // every external consumer of CostTracker.record breaks.
1034
+ const tracker = createCostTracker();
1035
+ const entry = {
1036
+ model: 'm',
1037
+ provider: 'p',
1038
+ operation: 'generate',
1039
+ usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
1040
+ cost: null,
1041
+ latencyMs: 0,
1042
+ };
1043
+ tracker.record(entry);
1044
+ expect(tracker.getRecords()).toHaveLength(1);
1045
+ expect(tracker.getRecords()[0]?.status).toBe('success');
1046
+ });
1047
+ it('a consumer-supplied CostTracker mock without status handling still satisfies the interface', () => {
1048
+ // Consumers writing their own tracker mocks (e.g. in framework tests)
1049
+ // should not have to know about the new `status` field.
1050
+ const seen = [];
1051
+ const tracker = {
1052
+ record(entry) {
1053
+ seen.push(entry);
1054
+ },
1055
+ checkBudget() {
1056
+ return { allowed: true };
1057
+ },
1058
+ getDailyUsage() {
1059
+ return { totalTokens: 0, totalCost: 0, costAvailable: false, requestCount: 0 };
1060
+ },
1061
+ getRecords() {
1062
+ return [];
1063
+ },
1064
+ };
1065
+ tracker.record({
1066
+ model: 'm',
1067
+ provider: 'p',
1068
+ operation: 'generate',
1069
+ usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 },
1070
+ cost: null,
1071
+ latencyMs: 0,
1072
+ });
1073
+ expect(seen).toHaveLength(1);
1074
+ });
1075
+ });
373
1076
  });
374
1077
  //# sourceMappingURL=ai-service.test.js.map