@cartanova/qgrid-cli 2.5.2 → 2.5.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 (68) hide show
  1. package/README.ko.md +7 -2
  2. package/README.md +7 -2
  3. package/bundle/dist/application/monit/monit.frame.js +3 -3
  4. package/bundle/dist/application/qgrid/oauth.js +1 -1
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +41 -15
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +74 -28
  7. package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
  8. package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -0
  9. package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
  10. package/bundle/dist/application/request-log/request-log.model.js +3 -3
  11. package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
  12. package/bundle/dist/application/token/token.model.js +2 -2
  13. package/bundle/dist/server-error-handler.js +15 -0
  14. package/bundle/dist/sonamu.config.js +4 -7
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +5 -4
  16. package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
  17. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
  18. package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
  19. package/bundle/dist/utils/providers/common/strictifier.js +329 -42
  20. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
  21. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
  22. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
  23. package/bundle/src/application/monit/monit.frame.ts +1 -1
  24. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
  25. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +358 -1
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +50 -28
  27. package/bundle/src/application/qgrid/qgrid.frame.test.ts +629 -8
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +107 -27
  29. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  30. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
  31. package/bundle/src/application/qgrid/tool-emulation-schema.ts +176 -0
  32. package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
  33. package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
  34. package/bundle/src/server-error-handler.ts +22 -0
  35. package/bundle/src/sonamu.config.test.ts +37 -0
  36. package/bundle/src/sonamu.config.ts +2 -5
  37. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +56 -0
  38. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +3 -3
  39. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
  40. package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
  41. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +66 -0
  42. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
  43. package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
  44. package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
  45. package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
  46. package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
  47. package/bundle/src/utils/providers/common/strictifier.ts +513 -52
  48. package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
  49. package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
  50. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
  51. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
  52. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
  53. package/bundle/web-dist/client/assets/{index-BQuKeBC5.js → index-DiY5V2e3.js} +3 -3
  54. package/bundle/web-dist/client/assets/{logs-BSsHMIKT.js → logs-UZ4Cvdmc.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-D5ypGmGi.js → monit-C5cJo-HD.js} +1 -1
  56. package/bundle/web-dist/client/assets/{routes-BsCbz225.js → routes-DEKEmoQN.js} +1 -1
  57. package/bundle/web-dist/client/assets/{show-CXPhLZCl.js → show-DnX6b8KG.js} +1 -1
  58. package/bundle/web-dist/client/assets/{tokens-gEGH52O8.js → tokens-Ct0FGD4b.js} +1 -1
  59. package/bundle/web-dist/client/index.html +1 -1
  60. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  61. package/package.json +1 -1
  62. package/skills/qgrid/references/ai-sdk-provider-contract.md +42 -2
  63. package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -1
  64. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  65. package/skills/qgrid/references/decision-rationale.md +11 -1
  66. package/skills/qgrid/references/repo-map.md +2 -1
  67. package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
  68. package/skills/qgrid/references/verification-and-debugging.md +16 -1
@@ -1,3 +1,5 @@
1
+ import { EventEmitter } from "node:events";
2
+
1
3
  import { Sonamu } from "sonamu";
2
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
5
 
@@ -54,15 +56,19 @@ vi.mock("../token/token.model", () => ({
54
56
  },
55
57
  }));
56
58
 
57
- vi.mock("./qgrid.dispatcher", () => ({
58
- QgridDispatcher: {
59
- query: dispatcherQueryMock,
60
- queryStream: dispatcherQueryStreamMock,
61
- openaiDispatcher: {
62
- getRateLimitsByTokenId: getRateLimitsByTokenIdMock,
59
+ vi.mock("./qgrid.dispatcher", async (importOriginal) => {
60
+ const original = await importOriginal<typeof import("./qgrid.dispatcher")>();
61
+ return {
62
+ ...original,
63
+ QgridDispatcher: {
64
+ query: dispatcherQueryMock,
65
+ queryStream: dispatcherQueryStreamMock,
66
+ openaiDispatcher: {
67
+ getRateLimitsByTokenId: getRateLimitsByTokenIdMock,
68
+ },
63
69
  },
64
- },
65
- }));
70
+ };
71
+ });
66
72
 
67
73
  vi.mock("./qgrid-run-lifecycle", () => ({
68
74
  beforeQuery: beforeQueryMock,
@@ -88,6 +94,10 @@ const tokenEntry = {
88
94
  weight: 1,
89
95
  };
90
96
 
97
+ function deeplyNestedInputSchema(depth: number): unknown {
98
+ return JSON.parse(`${"[".repeat(depth)}0${"]".repeat(depth)}`) as unknown;
99
+ }
100
+
91
101
  describe("QgridFrame.updateToken", () => {
92
102
  beforeEach(() => {
93
103
  findOneMock.mockReset();
@@ -144,6 +154,10 @@ describe("QgridFrame.updateToken", () => {
144
154
  });
145
155
 
146
156
  describe("QgridFrame.query request logging", () => {
157
+ afterEach(() => {
158
+ vi.restoreAllMocks();
159
+ });
160
+
147
161
  beforeEach(() => {
148
162
  beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
149
163
  afterQueryMock.mockReset().mockResolvedValue({});
@@ -203,6 +217,34 @@ describe("QgridFrame.query request logging", () => {
203
217
  expect(result.runContext).toEqual(queryOutput().runContext);
204
218
  });
205
219
 
220
+ it("persists an immediate tools+schema structured answer as final JSON text", async () => {
221
+ const output = {
222
+ ...queryOutput(),
223
+ text: '{"result":"ok"}',
224
+ content: [{ type: "text" as const, text: '{"result":"ok"}' }],
225
+ };
226
+ dispatcherQueryMock.mockResolvedValueOnce(output);
227
+ const args = {
228
+ prompt: "answer",
229
+ model: "openai/gpt-5.6-terra",
230
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
231
+ jsonSchema: JSON.stringify({
232
+ type: "object",
233
+ properties: { result: { type: "string" } },
234
+ required: ["result"],
235
+ }),
236
+ };
237
+
238
+ await expect(QgridFrame.query(args)).resolves.toMatchObject({
239
+ text: '{"result":"ok"}',
240
+ content: [{ type: "text", text: '{"result":"ok"}' }],
241
+ finishReason: "stop",
242
+ });
243
+ expect(beforeQueryMock).toHaveBeenCalledWith(args);
244
+ expect(afterQueryMock).toHaveBeenCalledWith(41, 0, args, output);
245
+ expect(finishRunWithErrorMock).not.toHaveBeenCalled();
246
+ });
247
+
206
248
  it("does not create or update logs when logger is false", async () => {
207
249
  const output = queryOutput();
208
250
  dispatcherQueryMock.mockResolvedValueOnce(output);
@@ -237,6 +279,59 @@ describe("QgridFrame.query request logging", () => {
237
279
  expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
238
280
  });
239
281
 
282
+ it("marks an incoherent structured envelope failure as a request-log error", async () => {
283
+ const error = new Error(
284
+ "tool-call emulation: structured response envelope is missing required keys: toolCalls",
285
+ );
286
+ dispatcherQueryMock.mockRejectedValueOnce(error);
287
+ const args = {
288
+ prompt: "answer",
289
+ model: "anthropic/claude-sonnet-4-6",
290
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
291
+ jsonSchema: JSON.stringify({ type: "object", properties: {} }),
292
+ };
293
+
294
+ await expect(QgridFrame.query(args)).rejects.toThrow(error.message);
295
+ expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, error.message, args);
296
+ expect(afterQueryMock).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it("aborts provider execution and marks the run aborted when the HTTP response closes early", async () => {
300
+ const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
301
+ const responseRaw = Object.assign(new EventEmitter(), {
302
+ destroyed: false,
303
+ writableEnded: false,
304
+ });
305
+ vi.spyOn(Sonamu, "getContext").mockReturnValue({
306
+ transport: "http",
307
+ request: { raw: requestRaw },
308
+ reply: { raw: responseRaw },
309
+ } as never);
310
+ let providerSignal: AbortSignal | undefined;
311
+ dispatcherQueryMock.mockImplementationOnce(
312
+ (_args: unknown, signal: AbortSignal) =>
313
+ new Promise((_resolve, reject) => {
314
+ providerSignal = signal;
315
+ signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
316
+ once: true,
317
+ });
318
+ }),
319
+ );
320
+ const args = { prompt: "hi", model: "anthropic/claude-opus-5" };
321
+
322
+ const queryPromise = QgridFrame.query(args);
323
+ await vi.waitFor(() => expect(providerSignal).toBeDefined());
324
+ responseRaw.destroyed = true;
325
+ responseRaw.emit("close");
326
+
327
+ await expect(queryPromise).rejects.toThrow("provider aborted");
328
+ expect(providerSignal?.aborted).toBe(true);
329
+ expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args);
330
+ expect(finishRunWithErrorMock).not.toHaveBeenCalled();
331
+ expect(requestRaw.listenerCount("aborted")).toBe(0);
332
+ expect(responseRaw.listenerCount("close")).toBe(0);
333
+ });
334
+
240
335
  it("returns the provider result when afterQuery persistence fails", async () => {
241
336
  const output = queryOutput();
242
337
  dispatcherQueryMock.mockResolvedValueOnce(output);
@@ -261,11 +356,365 @@ describe("QgridFrame.query request logging", () => {
261
356
  expect(dispatcherQueryMock).not.toHaveBeenCalled();
262
357
  });
263
358
 
359
+ it.each([
360
+ ["malformed JSON", '{"type":"object"'],
361
+ ["unsupported top-level array schema", '{"type":"array","items":{"type":"string"}}'],
362
+ ])("returns a deterministic 400 before logging or dispatch for %s", async (_label, jsonSchema) => {
363
+ await expect(
364
+ QgridFrame.query({
365
+ prompt: "hi",
366
+ model: "openai/gpt-5.5",
367
+ jsonSchema,
368
+ }),
369
+ ).rejects.toMatchObject({
370
+ statusCode: 400,
371
+ message: expect.stringContaining("qgrid: jsonSchema"),
372
+ });
373
+
374
+ expect(beforeQueryMock).not.toHaveBeenCalled();
375
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
376
+ });
377
+
378
+ it("returns 400 for a deeply nested tools-only schema before logging or dispatch", async () => {
379
+ await expect(
380
+ QgridFrame.query({
381
+ prompt: "hi",
382
+ model: "openai/gpt-5.5",
383
+ tools: [{ name: "deepTool", inputSchema: deeplyNestedInputSchema(256) }],
384
+ }),
385
+ ).rejects.toMatchObject({
386
+ statusCode: 400,
387
+ message: expect.stringContaining("tools[0].inputSchema exceeds depth limit"),
388
+ });
389
+
390
+ expect(beforeQueryMock).not.toHaveBeenCalled();
391
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("returns 400 for an unsupported composed-schema reference before logging or dispatch", async () => {
395
+ await expect(
396
+ QgridFrame.query({
397
+ prompt: "hi",
398
+ model: "openai/gpt-5.5",
399
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
400
+ jsonSchema: JSON.stringify({
401
+ $id: "urn:example:answer",
402
+ type: "object",
403
+ properties: { answer: { type: "string" } },
404
+ }),
405
+ }),
406
+ ).rejects.toMatchObject({
407
+ statusCode: 400,
408
+ message: expect.stringContaining("$id is not supported"),
409
+ });
410
+
411
+ expect(beforeQueryMock).not.toHaveBeenCalled();
412
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it("returns 400 for a malformed normalization keyword before logging or dispatch", async () => {
416
+ await expect(
417
+ QgridFrame.query({
418
+ prompt: "hi",
419
+ model: "openai/gpt-5.5",
420
+ jsonSchema: JSON.stringify({
421
+ type: "object",
422
+ anyOf: {},
423
+ }),
424
+ }),
425
+ ).rejects.toMatchObject({
426
+ statusCode: 400,
427
+ message: expect.stringContaining("caller schema cannot be normalized"),
428
+ });
429
+
430
+ expect(beforeQueryMock).not.toHaveBeenCalled();
431
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it("returns 400 for an unrestricted positional tuple before logging or dispatch", async () => {
435
+ await expect(
436
+ QgridFrame.query({
437
+ prompt: "hi",
438
+ model: "openai/gpt-5.6-terra",
439
+ jsonSchema: JSON.stringify({
440
+ type: "object",
441
+ properties: {
442
+ tuple: {
443
+ type: "array",
444
+ items: [{ type: "string" }],
445
+ additionalItems: true,
446
+ },
447
+ },
448
+ required: ["tuple"],
449
+ }),
450
+ }),
451
+ ).rejects.toMatchObject({
452
+ statusCode: 400,
453
+ message: expect.stringContaining("unrestricted tuple rest is not supported"),
454
+ });
455
+
456
+ expect(beforeQueryMock).not.toHaveBeenCalled();
457
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("returns 400 for an Anthropic positional tuple before logging or dispatch", async () => {
461
+ await expect(
462
+ QgridFrame.query({
463
+ prompt: "hi",
464
+ model: "anthropic/claude-opus-4-8",
465
+ jsonSchema: JSON.stringify({
466
+ type: "object",
467
+ properties: {
468
+ tuple: {
469
+ type: "array",
470
+ items: [{ type: "string" }, { type: "integer" }],
471
+ },
472
+ },
473
+ required: ["tuple"],
474
+ }),
475
+ }),
476
+ ).rejects.toMatchObject({
477
+ statusCode: 400,
478
+ message: expect.stringContaining(
479
+ "positional tuple schemas are not supported on Anthropic",
480
+ ),
481
+ });
482
+
483
+ expect(beforeQueryMock).not.toHaveBeenCalled();
484
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
485
+ });
486
+
487
+ it("returns 400 for an Anthropic tuple nested under not before logging or dispatch", async () => {
488
+ await expect(
489
+ QgridFrame.query({
490
+ prompt: "hi",
491
+ model: "anthropic/claude-opus-4-8",
492
+ jsonSchema: JSON.stringify({
493
+ type: "object",
494
+ not: {
495
+ type: "array",
496
+ items: [{ type: "string" }],
497
+ },
498
+ }),
499
+ }),
500
+ ).rejects.toMatchObject({
501
+ statusCode: 400,
502
+ message: expect.stringContaining(
503
+ "positional tuple schemas are not supported on Anthropic",
504
+ ),
505
+ });
506
+
507
+ expect(beforeQueryMock).not.toHaveBeenCalled();
508
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
509
+ });
510
+
511
+ it("returns 400 for an OpenAI tuple under not instead of changing negative semantics", async () => {
512
+ await expect(
513
+ QgridFrame.query({
514
+ prompt: "hi",
515
+ model: "openai/gpt-5.6-terra",
516
+ jsonSchema: JSON.stringify({
517
+ type: "object",
518
+ not: {
519
+ type: "array",
520
+ items: [{ type: "string" }],
521
+ },
522
+ }),
523
+ }),
524
+ ).rejects.toMatchObject({
525
+ statusCode: 400,
526
+ message: expect.stringContaining(
527
+ "positional tuple schemas are not supported in this schema position",
528
+ ),
529
+ });
530
+
531
+ expect(beforeQueryMock).not.toHaveBeenCalled();
532
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it("returns 400 for a definition referenced under not before globally normalizing it", async () => {
536
+ await expect(
537
+ QgridFrame.query({
538
+ prompt: "hi",
539
+ model: "openai/gpt-5.6-terra",
540
+ jsonSchema: JSON.stringify({
541
+ type: "object",
542
+ $defs: {
543
+ Forbidden: {
544
+ type: "object",
545
+ properties: { value: { const: 0 } },
546
+ required: ["value"],
547
+ },
548
+ },
549
+ not: { $ref: "#/$defs/Forbidden" },
550
+ }),
551
+ }),
552
+ ).rejects.toMatchObject({
553
+ statusCode: 400,
554
+ message: expect.stringContaining(
555
+ "schema references are not supported in this schema position",
556
+ ),
557
+ });
558
+
559
+ expect(beforeQueryMock).not.toHaveBeenCalled();
560
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
561
+ });
562
+
563
+ it.each([
564
+ [
565
+ "a literal JSON value",
566
+ {
567
+ type: "object",
568
+ properties: {
569
+ payload: { $ref: "#/$defs/Carrier/enum/0" },
570
+ },
571
+ required: ["payload"],
572
+ $defs: {
573
+ Carrier: {
574
+ enum: [{ type: "object", properties: { value: { type: "string" } } }],
575
+ },
576
+ },
577
+ },
578
+ "$ref target must be the document root or a definition root",
579
+ ],
580
+ [
581
+ "a conditional schema position",
582
+ {
583
+ type: "object",
584
+ properties: {
585
+ payload: { $ref: "#/if" },
586
+ },
587
+ required: ["payload"],
588
+ if: {
589
+ type: "object",
590
+ properties: { secret: { type: "string" } },
591
+ },
592
+ },
593
+ "$ref target must be the document root or a definition root",
594
+ ],
595
+ ] as const)(
596
+ "returns 400 for an OpenAI ref targeting %s before logging or dispatch",
597
+ async (_label, schema, message) => {
598
+ await expect(
599
+ QgridFrame.query({
600
+ prompt: "hi",
601
+ model: "openai/gpt-5.6-terra",
602
+ jsonSchema: JSON.stringify(schema),
603
+ }),
604
+ ).rejects.toMatchObject({
605
+ statusCode: 400,
606
+ message: expect.stringContaining(message),
607
+ });
608
+
609
+ expect(beforeQueryMock).not.toHaveBeenCalled();
610
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
611
+ },
612
+ );
613
+
614
+ it.each([
615
+ ["missing", undefined],
616
+ ["nullable union", ["array", "null"]],
617
+ ] as const)(
618
+ "returns 400 for an OpenAI positional tuple with %s type before logging or dispatch",
619
+ async (_label, type) => {
620
+ await expect(
621
+ QgridFrame.query({
622
+ prompt: "hi",
623
+ model: "openai/gpt-5.6-terra",
624
+ jsonSchema: JSON.stringify({
625
+ type: "object",
626
+ properties: {
627
+ tuple: {
628
+ ...(type === undefined ? {} : { type }),
629
+ items: [{ type: "string" }, { type: "integer" }],
630
+ },
631
+ },
632
+ required: ["tuple"],
633
+ }),
634
+ }),
635
+ ).rejects.toMatchObject({
636
+ statusCode: 400,
637
+ message: expect.stringContaining(
638
+ 'positional tuple schemas must declare type "array"',
639
+ ),
640
+ });
641
+
642
+ expect(beforeQueryMock).not.toHaveBeenCalled();
643
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
644
+ },
645
+ );
646
+
647
+ it.each([
648
+ ["missing", undefined],
649
+ ["nullable union", ["array", "null"]],
650
+ ] as const)(
651
+ "returns 400 for an Anthropic positional tuple with %s type before logging or dispatch",
652
+ async (_label, type) => {
653
+ await expect(
654
+ QgridFrame.query({
655
+ prompt: "hi",
656
+ model: "anthropic/claude-opus-4-8",
657
+ jsonSchema: JSON.stringify({
658
+ type: "object",
659
+ properties: {
660
+ tuple: {
661
+ ...(type === undefined ? {} : { type }),
662
+ prefixItems: [{ type: "string" }, { type: "integer" }],
663
+ },
664
+ },
665
+ required: ["tuple"],
666
+ }),
667
+ }),
668
+ ).rejects.toMatchObject({
669
+ statusCode: 400,
670
+ message: expect.stringContaining(
671
+ "positional tuple schemas are not supported on Anthropic",
672
+ ),
673
+ });
674
+
675
+ expect(beforeQueryMock).not.toHaveBeenCalled();
676
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
677
+ },
678
+ );
679
+
680
+ it("returns 400 when the final Anthropic schema exceeds the safe argv size", async () => {
681
+ await expect(
682
+ QgridFrame.query({
683
+ prompt: "hi",
684
+ model: "anthropic/claude-sonnet-4-6",
685
+ tools: [
686
+ {
687
+ name: "lookup",
688
+ description: "x".repeat(64 * 1024),
689
+ inputSchema: { type: "object" },
690
+ },
691
+ ],
692
+ jsonSchema: JSON.stringify({
693
+ type: "object",
694
+ properties: { answer: { type: "string" } },
695
+ }),
696
+ }),
697
+ ).rejects.toMatchObject({
698
+ statusCode: 400,
699
+ message: expect.stringContaining("Anthropic dispatch schema exceeds argv"),
700
+ });
701
+
702
+ expect(beforeQueryMock).not.toHaveBeenCalled();
703
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
704
+ });
705
+
264
706
  it("rejects legacy logMode in the wire schema", () => {
265
707
  expect(QueryInput.safeParse({ prompt: "hi", logMode: "none" }).success).toBe(false);
266
708
  expect(QueryInput.safeParse({ prompt: "hi", logMode: undefined }).success).toBe(false);
267
709
  expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
268
710
  });
711
+
712
+ it("accepts bounded positive integer timeout values only", () => {
713
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
714
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
715
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 1.5 }).success).toBe(false);
716
+ expect(QueryInput.safeParse({ prompt: "hi", timeout: 1_800_001 }).success).toBe(false);
717
+ });
269
718
  });
270
719
 
271
720
  describe("QgridFrame.prepareStream", () => {
@@ -286,6 +735,96 @@ describe("QgridFrame.prepareStream", () => {
286
735
  expect(dispatcherQueryMock).not.toHaveBeenCalled();
287
736
  expect(requestLogSaveMock).not.toHaveBeenCalled();
288
737
  });
738
+
739
+ it("returns 400 for an invalid schema before allocating a stream id", async () => {
740
+ await expect(
741
+ QgridFrame.prepareStream({
742
+ prompt: "hi",
743
+ model: "anthropic/claude-sonnet-4-6",
744
+ jsonSchema: "null",
745
+ }),
746
+ ).rejects.toMatchObject({
747
+ statusCode: 400,
748
+ message: 'qgrid: jsonSchema top-level type must be "object"',
749
+ });
750
+ });
751
+
752
+ it("returns 400 for an Anthropic tuple under patternProperties before allocating a stream id", async () => {
753
+ await expect(
754
+ QgridFrame.prepareStream({
755
+ prompt: "hi",
756
+ model: "anthropic/claude-opus-4-8",
757
+ jsonSchema: JSON.stringify({
758
+ type: "object",
759
+ patternProperties: {
760
+ "^tuple$": {
761
+ type: "array",
762
+ prefixItems: [{ type: "string" }],
763
+ },
764
+ },
765
+ }),
766
+ }),
767
+ ).rejects.toMatchObject({
768
+ statusCode: 400,
769
+ message: expect.stringContaining(
770
+ "positional tuple schemas are not supported on Anthropic",
771
+ ),
772
+ });
773
+
774
+ expect(requestLogSaveMock).not.toHaveBeenCalled();
775
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
776
+ });
777
+
778
+ it("returns 400 for a deeply nested tool schema before allocating a stream id", async () => {
779
+ await expect(
780
+ QgridFrame.prepareStream({
781
+ prompt: "hi",
782
+ model: "anthropic/claude-sonnet-4-6",
783
+ tools: [{ name: "deepTool", inputSchema: deeplyNestedInputSchema(256) }],
784
+ }),
785
+ ).rejects.toMatchObject({
786
+ statusCode: 400,
787
+ message: expect.stringContaining("tools[0].inputSchema exceeds depth limit"),
788
+ });
789
+ });
790
+
791
+ it("returns 400 for an unsupported composed-schema reference before allocating a stream id", async () => {
792
+ await expect(
793
+ QgridFrame.prepareStream({
794
+ prompt: "hi",
795
+ model: "anthropic/claude-sonnet-4-6",
796
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
797
+ jsonSchema: JSON.stringify({
798
+ type: "object",
799
+ properties: { answer: { $ref: "#named-anchor" } },
800
+ }),
801
+ }),
802
+ ).rejects.toMatchObject({
803
+ statusCode: 400,
804
+ message: expect.stringContaining(
805
+ "only root-relative JSON Pointer $ref values are supported",
806
+ ),
807
+ });
808
+ });
809
+
810
+ it("returns 400 for an oversized Anthropic argv schema before allocating a stream id", async () => {
811
+ await expect(
812
+ QgridFrame.prepareStream({
813
+ prompt: "hi",
814
+ model: "anthropic/claude-sonnet-4-6",
815
+ tools: [
816
+ {
817
+ name: "lookup",
818
+ description: "x".repeat(64 * 1024),
819
+ inputSchema: { type: "object" },
820
+ },
821
+ ],
822
+ }),
823
+ ).rejects.toMatchObject({
824
+ statusCode: 400,
825
+ message: expect.stringContaining("Anthropic dispatch schema exceeds argv"),
826
+ });
827
+ });
289
828
  });
290
829
 
291
830
  describe("QgridFrame raw lifecycle API", () => {
@@ -471,6 +1010,88 @@ describe("QgridFrame.queryStream request logging", () => {
471
1010
  expect(sse.publish).toHaveBeenCalledWith("error", { message: "provider failed" });
472
1011
  });
473
1012
 
1013
+ it.each([
1014
+ ["openai", "openai/gpt-5.6-terra", "gpt-5.6-terra"],
1015
+ ["anthropic", "anthropic/claude-sonnet-4-6", "claude-sonnet-4-6"],
1016
+ ] as const)(
1017
+ "finishes a malformed %s structured stream through the real dispatcher without hanging",
1018
+ async (provider, requestedModel, servedModel) => {
1019
+ const sse = installSseContext();
1020
+ const { QgridDispatcherClass } =
1021
+ await vi.importActual<typeof import("./qgrid.dispatcher")>("./qgrid.dispatcher");
1022
+ const dispatcher = new QgridDispatcherClass();
1023
+ const generateStream = vi.fn(
1024
+ async (
1025
+ _request: unknown,
1026
+ callbacks: {
1027
+ onComplete: (result: {
1028
+ text: string;
1029
+ tokenName: string;
1030
+ usage: {
1031
+ totalTokens: number;
1032
+ inputTokens: number;
1033
+ cachedInputTokens: number;
1034
+ outputTokens: number;
1035
+ reasoningOutputTokens: number;
1036
+ };
1037
+ durationMs: number;
1038
+ model: string;
1039
+ threadCoord: { workerId: number; threadId: string; epoch: number };
1040
+ }) => void;
1041
+ },
1042
+ ) => {
1043
+ // 실제 provider adapter 완료 지점: toolCalls 누락 envelope가 dispatcher mapper에서 throw.
1044
+ callbacks.onComplete({
1045
+ text: '{"action":"answer","answer":{"result":"ok"}}',
1046
+ tokenName: `${provider}/test`,
1047
+ usage: {
1048
+ totalTokens: 10,
1049
+ inputTokens: 5,
1050
+ cachedInputTokens: 0,
1051
+ outputTokens: 5,
1052
+ reasoningOutputTokens: 0,
1053
+ },
1054
+ durationMs: 12,
1055
+ model: servedModel,
1056
+ threadCoord: { workerId: 1, threadId: "thread-malformed", epoch: 0 },
1057
+ });
1058
+ },
1059
+ );
1060
+ if (provider === "openai") {
1061
+ dispatcher.openaiDispatcher = { generateStream } as never;
1062
+ } else {
1063
+ dispatcher.anthropicDispatcher = { generateStream } as never;
1064
+ }
1065
+ dispatcherQueryStreamMock.mockImplementationOnce((args, callbacks, signal) =>
1066
+ dispatcher.queryStream(args, callbacks, signal),
1067
+ );
1068
+
1069
+ const args = {
1070
+ prompt: "answer",
1071
+ model: requestedModel,
1072
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
1073
+ jsonSchema: JSON.stringify({
1074
+ type: "object",
1075
+ properties: { result: { type: "string" } },
1076
+ }),
1077
+ };
1078
+ const { streamId } = await QgridFrame.prepareStream(args);
1079
+
1080
+ await expect(QgridFrame.queryStream(streamId)).resolves.toBeUndefined();
1081
+
1082
+ expect(generateStream).toHaveBeenCalledOnce();
1083
+ expect(finishRunWithErrorMock).toHaveBeenCalledWith(
1084
+ 52,
1085
+ expect.stringContaining("invalid structured-output envelope"),
1086
+ args,
1087
+ );
1088
+ const errorMessage = finishRunWithErrorMock.mock.calls[0]?.[1];
1089
+ expect(afterQueryMock).not.toHaveBeenCalled();
1090
+ expect(sse.publish).toHaveBeenCalledWith("error", { message: errorMessage });
1091
+ expect(sse.end).toHaveBeenCalledOnce();
1092
+ },
1093
+ );
1094
+
474
1095
  it("marks the run aborted on close regardless of the provider error message", async () => {
475
1096
  const sse = installSseContext();
476
1097
  dispatcherQueryStreamMock.mockImplementationOnce(