@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.4

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 (206) hide show
  1. package/docs/http-api.md +80 -4
  2. package/package.json +1 -4
  3. package/src/api/__tests__/index.test.ts +443 -32
  4. package/src/api/index.ts +108 -127
  5. package/src/cli/__tests__/analyze-task.test.ts +40 -7
  6. package/src/cli/__tests__/index.test.ts +337 -17
  7. package/src/cli/__tests__/types.test.ts +0 -18
  8. package/src/cli/__tests__/update-pipeline-json.test.ts +19 -9
  9. package/src/cli/analyze-task.ts +3 -14
  10. package/src/cli/index.ts +73 -61
  11. package/src/cli/types.ts +0 -13
  12. package/src/cli/update-pipeline-json.ts +3 -14
  13. package/src/config/__tests__/paths.test.ts +46 -1
  14. package/src/config/__tests__/pipeline-registry.test.ts +767 -0
  15. package/src/config/__tests__/sse-events.test.ts +470 -0
  16. package/src/config/__tests__/statuses.test.ts +41 -0
  17. package/src/config/paths.ts +11 -2
  18. package/src/config/pipeline-registry.ts +258 -0
  19. package/src/config/sse-events.ts +122 -0
  20. package/src/config/statuses.ts +20 -1
  21. package/src/core/__tests__/agent-step.test.ts +115 -0
  22. package/src/core/__tests__/config.test.ts +545 -105
  23. package/src/core/__tests__/core-boot-resolution.test.ts +181 -0
  24. package/src/core/__tests__/job-concurrency.test.ts +260 -0
  25. package/src/core/__tests__/job-submission.test.ts +396 -0
  26. package/src/core/__tests__/job-view.test.ts +1341 -0
  27. package/src/core/__tests__/json-file.test.ts +111 -0
  28. package/src/core/__tests__/lifecycle-policy.test.ts +81 -7
  29. package/src/core/__tests__/logger.test.ts +22 -0
  30. package/src/core/__tests__/orchestrator-no-deprecated-exports.test.ts +41 -0
  31. package/src/core/__tests__/orchestrator.test.ts +615 -2
  32. package/src/core/__tests__/pipeline-runner.test.ts +946 -9
  33. package/src/core/__tests__/redact.test.ts +153 -0
  34. package/src/core/__tests__/runner-liveness.test.ts +910 -0
  35. package/src/core/__tests__/seed-naming.test.ts +57 -0
  36. package/src/core/__tests__/single-derivation.test.ts +159 -0
  37. package/src/core/__tests__/task-runner.test.ts +594 -3
  38. package/src/core/__tests__/task-telemetry.test.ts +241 -0
  39. package/src/core/__tests__/workspace-root-resolution-guard.test.ts +62 -0
  40. package/src/core/agent-step.ts +4 -1
  41. package/src/core/agent-types.ts +5 -4
  42. package/src/core/config.ts +134 -222
  43. package/src/core/control.ts +3 -0
  44. package/src/core/job-concurrency.ts +56 -20
  45. package/src/core/job-submission.ts +133 -0
  46. package/src/core/job-view.ts +473 -0
  47. package/src/core/json-file.ts +45 -0
  48. package/src/core/lifecycle-policy.ts +23 -8
  49. package/src/core/logger.ts +0 -29
  50. package/src/core/orchestrator.ts +200 -74
  51. package/src/core/pipeline-runner.ts +85 -53
  52. package/src/core/redact.ts +40 -0
  53. package/src/core/runner-liveness.ts +280 -0
  54. package/src/core/seed-naming.ts +9 -0
  55. package/src/core/status-writer.ts +27 -33
  56. package/src/core/task-runner.ts +356 -319
  57. package/src/core/task-telemetry.ts +107 -0
  58. package/src/harness/__tests__/subprocess.test.ts +112 -1
  59. package/src/harness/subprocess.ts +55 -16
  60. package/src/llm/__tests__/index.test.ts +684 -33
  61. package/src/llm/index.ts +310 -67
  62. package/src/providers/__tests__/alibaba.test.ts +67 -6
  63. package/src/providers/__tests__/anthropic.test.ts +35 -14
  64. package/src/providers/__tests__/base.test.ts +62 -0
  65. package/src/providers/__tests__/claude-code.test.ts +99 -14
  66. package/src/providers/__tests__/deepseek.test.ts +16 -6
  67. package/src/providers/__tests__/gemini.test.ts +47 -25
  68. package/src/providers/__tests__/moonshot.test.ts +27 -0
  69. package/src/providers/__tests__/openai.test.ts +262 -74
  70. package/src/providers/__tests__/opencode.test.ts +77 -0
  71. package/src/providers/__tests__/request-timeout-contract.test.ts +226 -0
  72. package/src/providers/__tests__/stream-accumulator.test.ts +52 -0
  73. package/src/providers/__tests__/types.test.ts +85 -0
  74. package/src/providers/__tests__/zero-fill-guard.test.ts +62 -0
  75. package/src/providers/__tests__/zhipu.test.ts +27 -14
  76. package/src/providers/alibaba.ts +20 -39
  77. package/src/providers/anthropic.ts +23 -11
  78. package/src/providers/base.ts +19 -0
  79. package/src/providers/claude-code.ts +27 -18
  80. package/src/providers/deepseek.ts +9 -28
  81. package/src/providers/gemini.ts +20 -58
  82. package/src/providers/moonshot.ts +15 -11
  83. package/src/providers/openai.ts +79 -61
  84. package/src/providers/opencode.ts +16 -33
  85. package/src/providers/stream-accumulator.ts +27 -21
  86. package/src/providers/types.ts +29 -4
  87. package/src/providers/zhipu.ts +15 -44
  88. package/src/task-analysis/__tests__/analyzer.test.ts +0 -0
  89. package/src/task-analysis/__tests__/enrichers-schema-deducer.test.ts +34 -2
  90. package/src/task-analysis/__tests__/no-ast-imports.test.ts +52 -0
  91. package/src/task-analysis/__tests__/repository.test.ts +65 -0
  92. package/src/task-analysis/__tests__/types.test.ts +63 -130
  93. package/src/task-analysis/analyzer.ts +91 -0
  94. package/src/task-analysis/enrichers/schema-deducer.ts +13 -2
  95. package/src/task-analysis/index.ts +2 -36
  96. package/src/task-analysis/repository.ts +45 -0
  97. package/src/task-analysis/types.ts +42 -58
  98. package/src/ui/client/__tests__/api.test.ts +143 -1
  99. package/src/ui/client/__tests__/bootstrap.test.ts +178 -62
  100. package/src/ui/client/__tests__/job-adapter.test.ts +203 -2
  101. package/src/ui/client/__tests__/load-state.test.ts +70 -0
  102. package/src/ui/client/__tests__/types.test.ts +66 -3
  103. package/src/ui/client/__tests__/useJobDetailWithUpdates.test.ts +390 -77
  104. package/src/ui/client/__tests__/useJobList.test.ts +198 -23
  105. package/src/ui/client/__tests__/useJobListWithUpdates.test.ts +218 -7
  106. package/src/ui/client/adapters/__tests__/job-adapter.test.ts +186 -0
  107. package/src/ui/client/adapters/job-adapter.ts +41 -16
  108. package/src/ui/client/api.ts +38 -15
  109. package/src/ui/client/bootstrap.ts +19 -14
  110. package/src/ui/client/hooks/useJobDetailWithUpdates.ts +73 -97
  111. package/src/ui/client/hooks/useJobList.ts +26 -31
  112. package/src/ui/client/hooks/useJobListWithUpdates.ts +42 -76
  113. package/src/ui/client/load-state.ts +20 -0
  114. package/src/ui/client/reducers/__tests__/job-events.test.ts +568 -0
  115. package/src/ui/client/reducers/job-events.ts +137 -0
  116. package/src/ui/client/types.ts +16 -20
  117. package/src/ui/components/DAGGrid.tsx +6 -1
  118. package/src/ui/components/JobDetail.tsx +12 -4
  119. package/src/ui/components/JobTable.tsx +41 -13
  120. package/src/ui/components/StageTimeline.tsx +8 -20
  121. package/src/ui/components/TaskAnalysisDisplay.tsx +48 -6
  122. package/src/ui/components/__tests__/DAGGrid.test.tsx +36 -0
  123. package/src/ui/components/__tests__/JobDetail.test.tsx +112 -0
  124. package/src/ui/components/__tests__/JobTable.test.tsx +137 -1
  125. package/src/ui/components/__tests__/StageTimeline.test.tsx +5 -6
  126. package/src/ui/components/__tests__/TaskAnalysisDisplay.test.tsx +64 -0
  127. package/src/ui/components/types.ts +35 -15
  128. package/src/ui/dist/assets/{index--RH3sAt3.js → index-DN3-zvtP.js} +4324 -263
  129. package/src/ui/dist/assets/index-DN3-zvtP.js.map +1 -0
  130. package/src/ui/dist/assets/style-CtZBnjlR.css +2 -0
  131. package/src/ui/dist/index.html +2 -2
  132. package/src/ui/pages/PipelineDetail.tsx +60 -4
  133. package/src/ui/pages/PromptPipelineDashboard.tsx +59 -7
  134. package/src/ui/pages/__tests__/PromptPipelineDashboard.test.tsx +114 -32
  135. package/src/ui/pages/__tests__/pages.test.tsx +236 -42
  136. package/src/ui/server/__tests__/concurrency-endpoint.test.ts +54 -0
  137. package/src/ui/server/__tests__/config-bridge-node.test.ts +34 -1
  138. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  139. package/src/ui/server/__tests__/embedded-assets.test.ts +66 -0
  140. package/src/ui/server/__tests__/file-endpoints.test.ts +183 -5
  141. package/src/ui/server/__tests__/gate-endpoints.test.ts +55 -0
  142. package/src/ui/server/__tests__/index.test.ts +63 -0
  143. package/src/ui/server/__tests__/job-control-endpoints.test.ts +512 -2
  144. package/src/ui/server/__tests__/job-endpoints.test.ts +158 -2
  145. package/src/ui/server/__tests__/read-static-path-guard.test.ts +94 -0
  146. package/src/ui/server/__tests__/router-root-isolation.test.ts +204 -0
  147. package/src/ui/server/__tests__/router-threading.test.ts +148 -0
  148. package/src/ui/server/__tests__/router.test.ts +104 -0
  149. package/src/ui/server/__tests__/sse-broadcast.test.ts +44 -0
  150. package/src/ui/server/__tests__/sse-enhancer.test.ts +70 -0
  151. package/src/ui/server/config-bridge-node.ts +8 -26
  152. package/src/ui/server/config-bridge.ts +3 -4
  153. package/src/ui/server/embedded-assets-imports.d.ts +44 -0
  154. package/src/ui/server/embedded-assets.ts +13 -2
  155. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  156. package/src/ui/server/endpoints/__tests__/pipeline-analysis-endpoint.test.ts +167 -0
  157. package/src/ui/server/endpoints/__tests__/schema-file-endpoint.test.ts +104 -0
  158. package/src/ui/server/endpoints/__tests__/task-analysis-endpoint.test.ts +103 -0
  159. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +162 -96
  160. package/src/ui/server/endpoints/concurrency-endpoint.ts +2 -1
  161. package/src/ui/server/endpoints/create-pipeline-endpoint.ts +14 -29
  162. package/src/ui/server/endpoints/file-endpoints.ts +15 -10
  163. package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
  164. package/src/ui/server/endpoints/job-control-endpoints.ts +129 -141
  165. package/src/ui/server/endpoints/job-endpoints.ts +7 -0
  166. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  167. package/src/ui/server/endpoints/pipeline-analysis-endpoint.ts +99 -11
  168. package/src/ui/server/endpoints/schema-file-endpoint.ts +11 -3
  169. package/src/ui/server/endpoints/task-analysis-endpoint.ts +21 -5
  170. package/src/ui/server/endpoints/task-save-endpoint.ts +1 -2
  171. package/src/ui/server/endpoints/upload-endpoints.ts +5 -40
  172. package/src/ui/server/index.ts +19 -14
  173. package/src/ui/server/job-reader.ts +14 -2
  174. package/src/ui/server/router.ts +33 -10
  175. package/src/ui/server/sse-broadcast.ts +14 -3
  176. package/src/ui/server/sse-enhancer.ts +12 -2
  177. package/src/ui/state/__tests__/schema-loader.test.ts +11 -1
  178. package/src/ui/state/__tests__/snapshot.test.ts +120 -14
  179. package/src/ui/state/__tests__/types.test.ts +104 -5
  180. package/src/ui/state/snapshot.ts +2 -2
  181. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +85 -2
  182. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +250 -22
  183. package/src/ui/state/transformers/list-transformer.ts +13 -3
  184. package/src/ui/state/transformers/status-transformer.ts +36 -170
  185. package/src/ui/state/types.ts +15 -48
  186. package/src/utils/__tests__/path-containment.test.ts +160 -0
  187. package/src/{ui/server/utils → utils}/path-containment.ts +21 -0
  188. package/src/task-analysis/__tests__/enrichers-analysis-writer.test.ts +0 -86
  189. package/src/task-analysis/__tests__/enrichers-artifact-resolver.test.ts +0 -124
  190. package/src/task-analysis/__tests__/extractors-artifacts.test.ts +0 -133
  191. package/src/task-analysis/__tests__/extractors-llm-calls.test.ts +0 -46
  192. package/src/task-analysis/__tests__/extractors-stages.test.ts +0 -52
  193. package/src/task-analysis/__tests__/index.test.ts +0 -143
  194. package/src/task-analysis/__tests__/parser.test.ts +0 -41
  195. package/src/task-analysis/__tests__/utils-ast.test.ts +0 -82
  196. package/src/task-analysis/enrichers/analysis-writer.ts +0 -75
  197. package/src/task-analysis/enrichers/artifact-resolver.ts +0 -89
  198. package/src/task-analysis/extractors/artifacts.ts +0 -143
  199. package/src/task-analysis/extractors/llm-calls.ts +0 -117
  200. package/src/task-analysis/extractors/stages.ts +0 -45
  201. package/src/task-analysis/parser.ts +0 -20
  202. package/src/task-analysis/utils/ast.ts +0 -45
  203. package/src/ui/dist/assets/index--RH3sAt3.js.map +0 -1
  204. package/src/ui/dist/assets/style-CSSKuMOe.css +0 -2
  205. package/src/ui/embedded-assets.js +0 -12
  206. package/src/ui/server/__tests__/path-containment.test.ts +0 -54
@@ -3,7 +3,9 @@ import {
3
3
  createProviderError,
4
4
  DEFAULT_REQUEST_TIMEOUT_MS,
5
5
  ensureJsonResponseFormat,
6
+ extractResponseSchema,
6
7
  extractMessages,
8
+ isJsonResponseFormat,
7
9
  isRetryableError,
8
10
  sleep,
9
11
  stripMarkdownFences,
@@ -305,6 +307,66 @@ describe("ensureJsonResponseFormat", () => {
305
307
  });
306
308
  });
307
309
 
310
+ describe("isJsonResponseFormat", () => {
311
+ it('returns true for "json"', () => {
312
+ expect(isJsonResponseFormat("json")).toBe(true);
313
+ });
314
+
315
+ it('returns true for "json_object"', () => {
316
+ expect(isJsonResponseFormat("json_object")).toBe(true);
317
+ });
318
+
319
+ it('returns true for { type: "json_object" }', () => {
320
+ expect(isJsonResponseFormat({ type: "json_object" })).toBe(true);
321
+ });
322
+
323
+ it("returns true for { json_schema: {} }", () => {
324
+ expect(isJsonResponseFormat({ json_schema: {} })).toBe(true);
325
+ });
326
+
327
+ it("returns false for undefined", () => {
328
+ expect(isJsonResponseFormat(undefined)).toBe(false);
329
+ });
330
+
331
+ it("returns false for null", () => {
332
+ expect(isJsonResponseFormat(null)).toBe(false);
333
+ });
334
+
335
+ it('returns false for ""', () => {
336
+ expect(isJsonResponseFormat("")).toBe(false);
337
+ });
338
+
339
+ it('returns false for "text"', () => {
340
+ expect(isJsonResponseFormat("text")).toBe(false);
341
+ });
342
+
343
+ it("returns false for an object with neither json_object type nor json_schema", () => {
344
+ expect(isJsonResponseFormat({ type: "text" })).toBe(false);
345
+ expect(isJsonResponseFormat({ foo: "bar" })).toBe(false);
346
+ });
347
+ });
348
+
349
+ describe("extractResponseSchema", () => {
350
+ it("returns the schema when json_schema is present", () => {
351
+ const schema = { type: "object", properties: { name: { type: "string" } } };
352
+ expect(extractResponseSchema({ json_schema: schema })).toBe(schema);
353
+ });
354
+
355
+ it("returns undefined for a plain string responseFormat", () => {
356
+ expect(extractResponseSchema("json")).toBeUndefined();
357
+ expect(extractResponseSchema("json_object")).toBeUndefined();
358
+ });
359
+
360
+ it("returns undefined when json_schema is absent", () => {
361
+ expect(extractResponseSchema({ type: "json_object" })).toBeUndefined();
362
+ });
363
+
364
+ it("returns undefined for undefined/null", () => {
365
+ expect(extractResponseSchema(undefined)).toBeUndefined();
366
+ expect(extractResponseSchema(null)).toBeUndefined();
367
+ });
368
+ });
369
+
308
370
  describe("createProviderError", () => {
309
371
  it("returns an Error with status, code, and details", () => {
310
372
  const err = createProviderError(500, { message: "server error" }, "fallback");
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
- import { ProviderJsonModeError, ProviderJsonParseError } from "../types.ts";
2
+ import { ProviderJsonParseError } from "../types.ts";
3
3
  import type { ClaudeCodeOptions } from "../types.ts";
4
4
 
5
5
  const baseOptions: ClaudeCodeOptions = {
@@ -86,18 +86,15 @@ describe("claudeCodeChat", () => {
86
86
  expect(result.content).toEqual(jsonPayload);
87
87
  });
88
88
 
89
- it("reports usage as zeros", async () => {
89
+ it("does not report usage on success", async () => {
90
90
  spawnMock.mockReturnValue(
91
91
  createMockProcFromText(JSON.stringify({ ok: true })),
92
92
  );
93
93
 
94
94
  const result = await claudeCodeChat(baseOptions);
95
95
 
96
- expect(result.usage).toEqual({
97
- prompt_tokens: 0,
98
- completion_tokens: 0,
99
- total_tokens: 0,
100
- });
96
+ expect(result.usage).toBeUndefined();
97
+ expect(result.usageAvailable).toBeUndefined();
101
98
  });
102
99
 
103
100
  it("uses custom model and maxTurns when provided", async () => {
@@ -143,6 +140,7 @@ describe("claudeCodeChat", () => {
143
140
  { role: "system", content: "Be concise." },
144
141
  { role: "user", content: "Hello" },
145
142
  ],
143
+ responseFormat: "json",
146
144
  });
147
145
 
148
146
  const callArgs = spawnMock.mock.calls[0]![0] as string[];
@@ -166,13 +164,17 @@ describe("claudeCodeChat", () => {
166
164
  expect(callArgs[callArgs.indexOf("--max-tokens") + 1]).toBe("2048");
167
165
  });
168
166
 
169
- it("throws ProviderJsonModeError when responseFormat is invalid", async () => {
170
- await expect(
171
- claudeCodeChat({
172
- ...baseOptions,
173
- responseFormat: "text",
174
- }),
175
- ).rejects.toBeInstanceOf(ProviderJsonModeError);
167
+ it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
168
+ spawnMock.mockReturnValue(
169
+ createMockProcFromText("not json, just text"),
170
+ );
171
+
172
+ const result = await claudeCodeChat({
173
+ messages: [{ role: "user", content: "Give me json." }],
174
+ });
175
+
176
+ expect(typeof result.content).toBe("string");
177
+ expect(result.content).toBe("not json, just text");
176
178
  });
177
179
 
178
180
  describe("streaming accumulation", () => {
@@ -200,6 +202,89 @@ describe("claudeCodeChat", () => {
200
202
  expect(result.content).toEqual({ ok: true });
201
203
  });
202
204
  });
205
+
206
+ describe("requestTimeoutMs (AC-12)", () => {
207
+ let realClearTimeout: typeof globalThis.clearTimeout;
208
+ let realSetTimeout: typeof globalThis.setTimeout;
209
+
210
+ beforeEach(() => {
211
+ vi.useFakeTimers();
212
+ realClearTimeout = globalThis.clearTimeout;
213
+ realSetTimeout = globalThis.setTimeout;
214
+ });
215
+
216
+ afterEach(() => {
217
+ vi.useRealTimers();
218
+ globalThis.clearTimeout = realClearTimeout;
219
+ globalThis.setTimeout = realSetTimeout;
220
+ });
221
+
222
+ it("applies requestTimeoutMs to the subprocess idle abort", async () => {
223
+ const TIMEOUT_MS = 1_000;
224
+ const killFn = vi.fn();
225
+ let resolveExit: (code: number | null) => void = () => {};
226
+ const mockExit = new Promise<number | null>((resolve) => {
227
+ resolveExit = resolve;
228
+ });
229
+ let closed = false;
230
+ const mockStdout = new ReadableStream<Uint8Array>({
231
+ start(controller) {
232
+ killFn.mockImplementation(() => {
233
+ if (!closed) {
234
+ closed = true;
235
+ controller.close();
236
+ resolveExit(1);
237
+ }
238
+ });
239
+ },
240
+ });
241
+ const mockStderr = new ReadableStream<Uint8Array>({
242
+ start(controller) {
243
+ controller.close();
244
+ },
245
+ });
246
+
247
+ spawnMock.mockReturnValue({
248
+ stdout: mockStdout,
249
+ stderr: mockStderr,
250
+ exited: mockExit,
251
+ exitCode: null,
252
+ pid: 12345,
253
+ kill: killFn,
254
+ ref: vi.fn(),
255
+ unref: vi.fn(),
256
+ } as unknown as ReturnType<typeof Bun.spawn>);
257
+
258
+ const promise = claudeCodeChat({
259
+ ...baseOptions,
260
+ requestTimeoutMs: TIMEOUT_MS,
261
+ maxRetries: 0,
262
+ });
263
+
264
+ let state: "pending" | "resolved" | "rejected" = "pending";
265
+ promise.then(
266
+ () => {
267
+ state = "resolved";
268
+ },
269
+ () => {
270
+ state = "rejected";
271
+ },
272
+ );
273
+
274
+ // Before the threshold: kill must not have been called.
275
+ vi.advanceTimersByTime(TIMEOUT_MS - 1);
276
+ for (let i = 0; i < 10; i++) await Promise.resolve();
277
+ expect(killFn).not.toHaveBeenCalled();
278
+ expect(state).toBe("pending");
279
+
280
+ // Cross the threshold: idle timer fires → onAbort → kill → stdout closes → adapter throws.
281
+ vi.advanceTimersByTime(1);
282
+ for (let i = 0; i < 50; i++) await Promise.resolve();
283
+
284
+ expect(state).toBe("rejected");
285
+ expect(killFn).toHaveBeenCalledTimes(1);
286
+ });
287
+ });
203
288
  });
204
289
 
205
290
  describe("isClaudeCodeAvailable", () => {
@@ -198,6 +198,7 @@ describe("deepseekChat", () => {
198
198
 
199
199
  await deepseekChat({
200
200
  messages: baseOptions.messages,
201
+ responseFormat: "json_object",
201
202
  });
202
203
 
203
204
  const body = JSON.parse(
@@ -207,6 +208,18 @@ describe("deepseekChat", () => {
207
208
  expect(body.temperature).toBe(0.7);
208
209
  });
209
210
 
211
+ it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
212
+ const events = makeOpenAiSseEvents(["not json, just text"]);
213
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
214
+
215
+ const result = await deepseekChat({
216
+ messages: [{ role: "user", content: "Give me json output please." }],
217
+ });
218
+
219
+ expect(typeof result.content).toBe("string");
220
+ expect(result.content).toBe("not json, just text");
221
+ });
222
+
210
223
  it("passes optional parameters when provided", async () => {
211
224
  const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
212
225
  fetchMock.mockResolvedValue(mockStreamingResponse(events));
@@ -277,7 +290,7 @@ describe("deepseekChat", () => {
277
290
  });
278
291
  });
279
292
 
280
- it("defaults usage to zeros when stream provides no usage", async () => {
293
+ it("returns undefined usage when accumulated stream reports none", async () => {
281
294
  const events = [
282
295
  'data: {"choices":[{"delta":{"content":"{\\"ok\\":true}"}}]}\n\n',
283
296
  'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
@@ -287,11 +300,8 @@ describe("deepseekChat", () => {
287
300
  fetchMock.mockResolvedValue(mockStreamingResponse(events));
288
301
 
289
302
  const result = await deepseekChat(baseOptions);
290
- expect(result.usage).toEqual({
291
- prompt_tokens: 0,
292
- completion_tokens: 0,
293
- total_tokens: 0,
294
- });
303
+ expect(result.content).toEqual({ ok: true });
304
+ expect(result.usage).toBeUndefined();
295
305
  });
296
306
 
297
307
  it("skips SSE comment lines and empty lines", async () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { geminiChat } from "../gemini.ts";
3
- import { ProviderJsonModeError, ProviderJsonParseError } from "../types.ts";
3
+ import { ProviderJsonParseError } from "../types.ts";
4
4
  import type { GeminiOptions } from "../types.ts";
5
5
  import type { Mock } from "vitest";
6
6
 
@@ -171,7 +171,7 @@ describe("geminiChat", () => {
171
171
  ]);
172
172
  });
173
173
 
174
- it("injects JSON schema into system instruction when responseFormat has json_schema", async () => {
174
+ it("sets native responseSchema in generationConfig when responseFormat has json_schema (AC-14)", async () => {
175
175
  const schema = {
176
176
  type: "object",
177
177
  properties: { name: { type: "string" } },
@@ -191,29 +191,28 @@ describe("geminiChat", () => {
191
191
  (fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
192
192
  );
193
193
 
194
- const systemText = body.systemInstruction.parts[0].text as string;
195
- expect(systemText).toContain("You are helpful.");
196
- expect(systemText).toContain("Respond with JSON matching this schema:");
197
- expect(systemText).toContain('"type": "object"');
194
+ expect(body.generationConfig.responseMimeType).toBe("application/json");
195
+ expect(body.generationConfig.responseSchema).toEqual(schema);
196
+ const systemText = body.systemInstruction?.parts?.[0]?.text ?? "";
197
+ expect(systemText).not.toContain("Respond with JSON matching this schema");
198
+ expect(systemText).toBe("You are helpful.");
198
199
  });
199
200
 
200
- it("creates systemInstruction from schema alone when no system message exists", async () => {
201
- const schema = { type: "object" };
202
- const events = makeGeminiSseEvents(JSON.stringify({}));
201
+ it("does not set responseSchema when responseFormat is a plain json_object", async () => {
202
+ const events = makeGeminiSseEvents(JSON.stringify({ ok: true }));
203
203
  fetchMock.mockResolvedValue(mockStreamingResponse(events));
204
204
 
205
205
  await geminiChat({
206
- messages: [{ role: "user", content: "Do something." }],
207
- responseFormat: { json_schema: schema },
206
+ messages: [{ role: "user", content: "ok" }],
207
+ responseFormat: "json_object",
208
208
  });
209
209
 
210
210
  const body = JSON.parse(
211
211
  (fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
212
212
  );
213
213
 
214
- expect(body.systemInstruction).toBeDefined();
215
- const systemText = body.systemInstruction.parts[0].text as string;
216
- expect(systemText).toContain("Respond with JSON matching this schema:");
214
+ expect(body.generationConfig.responseMimeType).toBe("application/json");
215
+ expect(body.generationConfig.responseSchema).toBeUndefined();
217
216
  });
218
217
 
219
218
  it("normalizes usage from Gemini's usageMetadata format", async () => {
@@ -295,13 +294,21 @@ describe("geminiChat", () => {
295
294
  expect(result.content).toEqual(jsonPayload);
296
295
  });
297
296
 
298
- it("throws ProviderJsonModeError when responseFormat is invalid", async () => {
299
- await expect(
300
- geminiChat({
301
- ...baseOptions,
302
- responseFormat: "text",
303
- }),
304
- ).rejects.toThrow(ProviderJsonModeError);
297
+ it("does not set responseMimeType when responseFormat is omitted even if the message says 'json' (AC-13)", async () => {
298
+ const events = makeGeminiSseEvents("not json, just text");
299
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
300
+
301
+ const result = await geminiChat({
302
+ messages: [{ role: "user", content: "Give me json output please." }],
303
+ });
304
+
305
+ const body = JSON.parse(
306
+ (fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
307
+ );
308
+ expect(body.generationConfig.responseMimeType).toBeUndefined();
309
+ expect(body.generationConfig.responseSchema).toBeUndefined();
310
+ expect(typeof result.content).toBe("string");
311
+ expect(result.content).toBe("not json, just text");
305
312
  });
306
313
 
307
314
  it("throws ProviderJsonParseError for non-JSON text in JSON mode", async () => {
@@ -371,7 +378,7 @@ describe("geminiChat", () => {
371
378
  expect(init.signal).toBeInstanceOf(AbortSignal);
372
379
  });
373
380
 
374
- it("handles missing usageMetadata by defaulting to zeros", async () => {
381
+ it("returns undefined usage when accumulated stream reports none", async () => {
375
382
  // Stream without usageMetadata
376
383
  const events = [
377
384
  `data: ${JSON.stringify({
@@ -382,10 +389,25 @@ describe("geminiChat", () => {
382
389
 
383
390
  const result = await geminiChat(baseOptions);
384
391
 
392
+ expect(result.content).toEqual({ ok: true });
393
+ expect(result.usage).toBeUndefined();
394
+ });
395
+
396
+ it("passes through reported partial usage without coercion", async () => {
397
+ const jsonPayload = { partial: true };
398
+ const events = [
399
+ `data: ${JSON.stringify({
400
+ candidates: [{ content: { parts: [{ text: JSON.stringify(jsonPayload) }], role: "model" }, finishReason: "STOP" }],
401
+ usageMetadata: { promptTokenCount: 7 },
402
+ })}\n\n`,
403
+ ];
404
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
405
+
406
+ const result = await geminiChat(baseOptions);
407
+
408
+ expect(result.content).toEqual(jsonPayload);
385
409
  expect(result.usage).toEqual({
386
- prompt_tokens: 0,
387
- completion_tokens: 0,
388
- total_tokens: 0,
410
+ prompt_tokens: 7,
389
411
  });
390
412
  });
391
413
 
@@ -74,6 +74,7 @@ const baseOptions: MoonshotOptions = {
74
74
  { role: "system", content: "You are helpful." },
75
75
  { role: "user", content: "Return JSON." },
76
76
  ],
77
+ responseFormat: "json",
77
78
  };
78
79
 
79
80
  describe("moonshotChat", () => {
@@ -141,6 +142,18 @@ describe("moonshotChat", () => {
141
142
  expect(body.response_format).toBeUndefined();
142
143
  });
143
144
 
145
+ it("returns plain text content when responseFormat is omitted (AC-13)", async () => {
146
+ const events = makeOpenAiSseEvents(["not json, just text"]);
147
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
148
+
149
+ const result = await moonshotChat({
150
+ messages: [{ role: "user", content: "Give me json output please." }],
151
+ });
152
+
153
+ expect(typeof result.content).toBe("string");
154
+ expect(result.content).toBe("not json, just text");
155
+ });
156
+
144
157
  it("throws before fetch when messages are empty", async () => {
145
158
  await expect(
146
159
  moonshotChat({ messages: [] }),
@@ -384,6 +397,20 @@ describe("moonshotChat", () => {
384
397
  });
385
398
  });
386
399
 
400
+ it("returns undefined usage when accumulated stream reports none", async () => {
401
+ const events = [
402
+ 'data: {"choices":[{"delta":{"content":"{\\"ok\\":true}"}}]}\n\n',
403
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
404
+ "data: [DONE]\n\n",
405
+ ];
406
+
407
+ fetchMock.mockResolvedValue(mockStreamingResponse(events));
408
+
409
+ const result = await moonshotChat(baseOptions);
410
+ expect(result.content).toEqual({ ok: true });
411
+ expect(result.usage).toBeUndefined();
412
+ });
413
+
387
414
  it("retries on timeout then succeeds on second attempt", async () => {
388
415
  const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
389
416