@cartanova/qgrid-cli 2.5.3 → 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 (62) 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 +36 -14
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +19 -5
  7. package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -0
  8. package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
  9. package/bundle/dist/application/request-log/request-log.model.js +3 -3
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
  11. package/bundle/dist/application/token/token.model.js +2 -2
  12. package/bundle/dist/server-error-handler.js +15 -0
  13. package/bundle/dist/sonamu.config.js +4 -7
  14. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +4 -3
  15. package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
  16. package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
  17. package/bundle/dist/utils/providers/common/strictifier.js +329 -42
  18. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
  19. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
  20. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
  21. package/bundle/src/application/monit/monit.frame.ts +1 -1
  22. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
  23. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +337 -0
  24. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +45 -25
  25. package/bundle/src/application/qgrid/qgrid.frame.test.ts +580 -8
  26. package/bundle/src/application/qgrid/qgrid.frame.ts +28 -1
  27. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
  28. package/bundle/src/application/qgrid/tool-emulation-schema.ts +176 -0
  29. package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
  30. package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
  31. package/bundle/src/server-error-handler.ts +22 -0
  32. package/bundle/src/sonamu.config.test.ts +37 -0
  33. package/bundle/src/sonamu.config.ts +2 -5
  34. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +44 -0
  35. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +2 -2
  36. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
  37. package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
  38. package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
  39. package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
  40. package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
  41. package/bundle/src/utils/providers/common/strictifier.ts +513 -52
  42. package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
  43. package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
  44. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
  45. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
  46. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
  47. package/bundle/web-dist/client/assets/{index-DXdGBfB8.js → index-DiY5V2e3.js} +3 -3
  48. package/bundle/web-dist/client/assets/{logs-D_j1yxC4.js → logs-UZ4Cvdmc.js} +1 -1
  49. package/bundle/web-dist/client/assets/{monit-CIToyoxW.js → monit-C5cJo-HD.js} +1 -1
  50. package/bundle/web-dist/client/assets/{routes-Dd21A-wi.js → routes-DEKEmoQN.js} +1 -1
  51. package/bundle/web-dist/client/assets/{show-DfePWRbc.js → show-DnX6b8KG.js} +1 -1
  52. package/bundle/web-dist/client/assets/{tokens-B4MV2fdk.js → tokens-Ct0FGD4b.js} +1 -1
  53. package/bundle/web-dist/client/index.html +1 -1
  54. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  55. package/package.json +1 -1
  56. package/skills/qgrid/references/ai-sdk-provider-contract.md +34 -2
  57. package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -0
  58. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  59. package/skills/qgrid/references/decision-rationale.md +10 -1
  60. package/skills/qgrid/references/repo-map.md +2 -1
  61. package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
  62. package/skills/qgrid/references/verification-and-debugging.md +16 -1
@@ -10,6 +10,10 @@
10
10
 
11
11
  export type JsonSchema = Record<string, unknown>;
12
12
 
13
+ export interface StrictifierOptions {
14
+ provider?: string;
15
+ }
16
+
13
17
  export class StrictifierError extends Error {
14
18
  constructor(
15
19
  message: string,
@@ -20,71 +24,377 @@ export class StrictifierError extends Error {
20
24
  }
21
25
  }
22
26
 
23
- export function strictify(schema: JsonSchema): JsonSchema {
24
- return strictifyNode(schema, "$");
27
+ export function strictify(schema: JsonSchema, options: StrictifierOptions = {}): JsonSchema {
28
+ if (options.provider === "openai" || options.provider === "anthropic") {
29
+ assertNormalizationCompatibility(schema, options.provider);
30
+ }
31
+ const result = strictifyNode(schema, "$", options.provider);
32
+ if (options.provider === "openai" || options.provider === "anthropic") {
33
+ assertNormalizationCompatibility(result, options.provider);
34
+ }
35
+ return result;
25
36
  }
26
37
 
27
- function strictifyNode(node: JsonSchema, path: string): JsonSchema {
28
- if (typeof node !== "object" || node === null) return node;
38
+ const SCHEMA_SCAN_MAP_KEYWORDS = [
39
+ ["$defs", true],
40
+ ["definitions", true],
41
+ ["properties", true],
42
+ ["dependentSchemas", false],
43
+ ["patternProperties", false],
44
+ ] as const;
45
+ const SCHEMA_SCAN_ARRAY_KEYWORDS = [
46
+ ["allOf", true],
47
+ ["anyOf", true],
48
+ ["oneOf", true],
49
+ ["prefixItems", true],
50
+ ] as const;
51
+ const SCHEMA_SCAN_SINGLE_KEYWORDS = [
52
+ ["additionalProperties", false],
53
+ ["contains", false],
54
+ ["contentSchema", false],
55
+ ["else", false],
56
+ ["if", false],
57
+ ["not", false],
58
+ ["propertyNames", false],
59
+ ["then", false],
60
+ ["unevaluatedItems", false],
61
+ ["unevaluatedProperties", false],
62
+ ] as const;
63
+
64
+ /**
65
+ * Inspect every JSON Schema-bearing position without rewriting it.
66
+ *
67
+ * Positive positions already traversed by strictifyNode may normalize OpenAI tuples. Positions
68
+ * such as `not` and `if` cannot be tightened without changing caller semantics, so positional
69
+ * tuples there fail explicitly instead of leaking an unsupported provider schema. References
70
+ * from those positions also fail because their definitions are normalized globally.
71
+ */
72
+ function assertNormalizationCompatibility(
73
+ schema: JsonSchema,
74
+ provider: "openai" | "anthropic",
75
+ ): void {
76
+ const references: Array<{ value: string; path: string }> = [];
77
+ const stack: Array<{ node: JsonSchema; path: string; normalizable: boolean }> = [
78
+ { node: schema, path: "$", normalizable: true },
79
+ ];
80
+
81
+ while (stack.length > 0) {
82
+ const current = stack.pop()!;
83
+ const itemsValue = ownValue(current.node, "items");
84
+ const prefixItemsValue = ownValue(current.node, "prefixItems");
85
+ const legacyTuple = Array.isArray(itemsValue);
86
+ const prefixTuple = Array.isArray(prefixItemsValue);
87
+
88
+ assertUnsupportedReferenceKeywords(current.node, current.path);
89
+ collectSchemaReference(current, references);
90
+
91
+ if (legacyTuple || prefixTuple) {
92
+ const tuple = (legacyTuple ? itemsValue : prefixItemsValue) as unknown[];
93
+ if (provider === "anthropic" && tuple.length > 0) {
94
+ throw new StrictifierError(
95
+ "positional tuple schemas are not supported on Anthropic because Claude Code cannot preserve item positions",
96
+ current.path,
97
+ );
98
+ }
99
+ if (!current.normalizable) {
100
+ throw new StrictifierError(
101
+ "positional tuple schemas are not supported in this schema position",
102
+ current.path,
103
+ );
104
+ }
105
+ if (ownValue(current.node, "type") !== "array") {
106
+ throw new StrictifierError(
107
+ 'positional tuple schemas must declare type "array"; express nullable tuples with anyOf',
108
+ `${current.path}.type`,
109
+ );
110
+ }
111
+ }
112
+
113
+ for (const [keyword, keywordNormalizable] of SCHEMA_SCAN_MAP_KEYWORDS) {
114
+ const schemas = ownValue(current.node, keyword);
115
+ if (!isSchemaObject(schemas)) continue;
116
+ for (const [name, child] of Object.entries(schemas)) {
117
+ if (!isSchemaObject(child)) continue;
118
+ stack.push({
119
+ node: child,
120
+ path: `${current.path}.${keyword}.${name}`,
121
+ normalizable: current.normalizable && keywordNormalizable,
122
+ });
123
+ }
124
+ }
125
+
126
+ const dependencies = ownValue(current.node, "dependencies");
127
+ if (isSchemaObject(dependencies)) {
128
+ for (const [name, child] of Object.entries(dependencies)) {
129
+ if (!isSchemaObject(child)) continue;
130
+ stack.push({
131
+ node: child,
132
+ path: `${current.path}.dependencies.${name}`,
133
+ normalizable: false,
134
+ });
135
+ }
136
+ }
137
+
138
+ for (const [keyword, keywordNormalizable] of SCHEMA_SCAN_ARRAY_KEYWORDS) {
139
+ const schemas = ownValue(current.node, keyword);
140
+ if (!Array.isArray(schemas)) continue;
141
+ for (let index = 0; index < schemas.length; index += 1) {
142
+ const child = schemas[index];
143
+ if (!isSchemaObject(child)) continue;
144
+ stack.push({
145
+ node: child,
146
+ path: `${current.path}.${keyword}[${index}]`,
147
+ normalizable: current.normalizable && keywordNormalizable,
148
+ });
149
+ }
150
+ }
151
+
152
+ const items = ownValue(current.node, "items");
153
+ if (isSchemaObject(items)) {
154
+ stack.push({
155
+ node: items,
156
+ path: `${current.path}.items`,
157
+ normalizable: current.normalizable,
158
+ });
159
+ } else if (Array.isArray(items)) {
160
+ for (let index = 0; index < items.length; index += 1) {
161
+ const child = items[index];
162
+ if (!isSchemaObject(child)) continue;
163
+ stack.push({
164
+ node: child,
165
+ path: `${current.path}.items[${index}]`,
166
+ normalizable: current.normalizable,
167
+ });
168
+ }
169
+ }
170
+
171
+ const additionalItems = ownValue(current.node, "additionalItems");
172
+ if (isSchemaObject(additionalItems)) {
173
+ stack.push({
174
+ node: additionalItems,
175
+ path: `${current.path}.additionalItems`,
176
+ normalizable: current.normalizable && legacyTuple,
177
+ });
178
+ }
179
+
180
+ for (const [keyword, keywordNormalizable] of SCHEMA_SCAN_SINGLE_KEYWORDS) {
181
+ const child = ownValue(current.node, keyword);
182
+ if (!isSchemaObject(child)) continue;
183
+ stack.push({
184
+ node: child,
185
+ path: `${current.path}.${keyword}`,
186
+ normalizable: current.normalizable && keywordNormalizable,
187
+ });
188
+ }
189
+ }
190
+
191
+ assertReferenceTargets(schema, references);
192
+ }
193
+
194
+ const UNSUPPORTED_REFERENCE_KEYWORDS = [
195
+ "$id",
196
+ "id",
197
+ "$anchor",
198
+ "$dynamicAnchor",
199
+ "$recursiveAnchor",
200
+ "$dynamicRef",
201
+ "$recursiveRef",
202
+ ] as const;
203
+
204
+ function assertUnsupportedReferenceKeywords(schema: JsonSchema, path: string): void {
205
+ for (const keyword of UNSUPPORTED_REFERENCE_KEYWORDS) {
206
+ if (Object.hasOwn(schema, keyword)) {
207
+ throw new StrictifierError(`${keyword} is not supported`, `${path}.${keyword}`);
208
+ }
209
+ }
210
+ }
29
211
 
30
- const type = node.type as string | undefined;
212
+ function collectSchemaReference(
213
+ current: { node: JsonSchema; path: string; normalizable: boolean },
214
+ references: Array<{ value: string; path: string }>,
215
+ ): void {
216
+ if (!Object.hasOwn(current.node, "$ref")) return;
31
217
 
32
- if (type === "object" || node.properties) {
33
- return strictifyObject(node, path);
218
+ const reference = current.node.$ref;
219
+ if (typeof reference !== "string") {
220
+ throw new StrictifierError("$ref must be a string", `${current.path}.$ref`);
221
+ }
222
+ if (!current.normalizable) {
223
+ throw new StrictifierError(
224
+ "schema references are not supported in this schema position",
225
+ current.path,
226
+ );
34
227
  }
35
228
 
36
- if (type === "array") {
37
- return strictifyArray(node, path);
229
+ references.push({ value: reference, path: `${current.path}.$ref` });
230
+ }
231
+
232
+ function assertReferenceTargets(
233
+ root: JsonSchema,
234
+ references: Array<{ value: string; path: string }>,
235
+ ): void {
236
+ for (const reference of references) {
237
+ const tokens = parseLocalJsonPointer(reference.value, reference.path);
238
+ if (!isDefinitionRootPointer(tokens)) {
239
+ throw new StrictifierError(
240
+ "$ref target must be the document root or a definition root",
241
+ reference.path,
242
+ );
243
+ }
244
+ resolveDefinitionRoot(root, tokens, reference.path);
38
245
  }
246
+ }
39
247
 
40
- if (node.anyOf) {
41
- return {
42
- ...node,
43
- anyOf: (node.anyOf as JsonSchema[]).map((s, i) => strictifyNode(s, `${path}.anyOf[${i}]`)),
44
- };
248
+ function isDefinitionRootPointer(tokens: string[]): boolean {
249
+ if (tokens.length === 0) return true;
250
+ if (tokens.length % 2 !== 0) return false;
251
+
252
+ for (let index = 0; index < tokens.length; index += 2) {
253
+ if (tokens[index] !== "$defs" && tokens[index] !== "definitions") return false;
45
254
  }
255
+ return true;
256
+ }
46
257
 
47
- if (node.oneOf) {
48
- return {
49
- ...node,
50
- oneOf: (node.oneOf as JsonSchema[]).map((s, i) => strictifyNode(s, `${path}.oneOf[${i}]`)),
51
- };
258
+ function parseLocalJsonPointer(reference: string, path: string): string[] {
259
+ if (reference === "#") return [];
260
+ if (!reference.startsWith("#/")) {
261
+ throw new StrictifierError(
262
+ "only local root-relative JSON Pointer $ref values are supported",
263
+ path,
264
+ );
52
265
  }
53
266
 
54
- if (node.allOf) {
55
- return {
56
- ...node,
57
- allOf: (node.allOf as JsonSchema[]).map((s, i) => strictifyNode(s, `${path}.allOf[${i}]`)),
58
- };
267
+ let fragment: string;
268
+ try {
269
+ fragment = decodeURIComponent(reference.slice(1));
270
+ } catch {
271
+ throw new StrictifierError("$ref contains invalid percent encoding", path);
59
272
  }
273
+ if (!fragment.startsWith("/")) {
274
+ throw new StrictifierError(
275
+ "only local root-relative JSON Pointer $ref values are supported",
276
+ path,
277
+ );
278
+ }
279
+
280
+ return fragment
281
+ .slice(1)
282
+ .split("/")
283
+ .map((token) => decodeJsonPointerToken(token, path));
284
+ }
60
285
 
61
- if (node.$ref) {
62
- return { ...node };
286
+ function resolveDefinitionRoot(root: JsonSchema, tokens: string[], path: string): void {
287
+ let current = root;
288
+ for (let index = 0; index < tokens.length; index += 2) {
289
+ const keyword = tokens[index]!;
290
+ const name = tokens[index + 1]!;
291
+ const definitions = ownValue(current, keyword);
292
+ if (!isSchemaObject(definitions) || !Object.hasOwn(definitions, name)) {
293
+ throw new StrictifierError("$ref does not resolve inside the caller schema", path);
294
+ }
295
+ const target = definitions[name];
296
+ if (!isSchemaObject(target)) {
297
+ throw new StrictifierError("$ref target must be an object schema", path);
298
+ }
299
+ current = target;
63
300
  }
301
+ }
64
302
 
65
- return { ...node };
303
+ function decodeJsonPointerToken(rawToken: string, path: string): string {
304
+ let token = "";
305
+ for (let index = 0; index < rawToken.length; index += 1) {
306
+ const character = rawToken[index]!;
307
+ if (character !== "~") {
308
+ token += character;
309
+ continue;
310
+ }
311
+
312
+ const escaped = rawToken[index + 1];
313
+ if (escaped === "0") {
314
+ token += "~";
315
+ } else if (escaped === "1") {
316
+ token += "/";
317
+ } else {
318
+ throw new StrictifierError("$ref contains an invalid JSON Pointer escape", path);
319
+ }
320
+ index += 1;
321
+ }
322
+ return token;
66
323
  }
67
324
 
68
- function strictifyObject(node: JsonSchema, path: string): JsonSchema {
69
- const properties = node.properties as Record<string, JsonSchema> | undefined;
325
+ function strictifyNode(node: JsonSchema, path: string, provider?: string): JsonSchema {
326
+ if (typeof node !== "object" || node === null) return node;
327
+
328
+ const dialectNeutralNode = provider === "openai" ? omitKeys(node, ["$schema"]) : node;
329
+ let result = strictifySchemaMaps(dialectNeutralNode, path, provider);
330
+ const type = ownValue(result, "type") as string | undefined;
331
+
332
+ if (type === "object" || Object.hasOwn(result, "properties")) {
333
+ result = strictifyObject(result, path, provider);
334
+ }
335
+
336
+ if (type === "array" || Object.hasOwn(result, "items") || Object.hasOwn(result, "prefixItems")) {
337
+ result = strictifyArray(result, path, provider);
338
+ }
339
+
340
+ return strictifySchemaArrays(result, path, provider);
341
+ }
342
+
343
+ const SCHEMA_MAP_KEYWORDS = ["$defs", "definitions"] as const;
344
+
345
+ function strictifySchemaMaps(node: JsonSchema, path: string, provider?: string): JsonSchema {
346
+ const result = { ...node };
347
+
348
+ for (const keyword of SCHEMA_MAP_KEYWORDS) {
349
+ const schemas = ownValue(node, keyword);
350
+ if (!isSchemaObject(schemas)) continue;
351
+
352
+ result[keyword] = Object.fromEntries(
353
+ Object.entries(schemas).map(([name, schema]) => [
354
+ name,
355
+ isSchemaObject(schema)
356
+ ? strictifyNode(schema, `${path}.${keyword}.${name}`, provider)
357
+ : schema,
358
+ ]),
359
+ );
360
+ }
361
+
362
+ return result;
363
+ }
364
+
365
+ const SCHEMA_ARRAY_KEYWORDS = ["anyOf", "oneOf", "allOf"] as const;
366
+
367
+ function strictifySchemaArrays(node: JsonSchema, path: string, provider?: string): JsonSchema {
368
+ const result = { ...node };
369
+
370
+ for (const keyword of SCHEMA_ARRAY_KEYWORDS) {
371
+ const schemas = ownValue(node, keyword);
372
+ if (!schemas) continue;
373
+ result[keyword] = (schemas as JsonSchema[]).map((schema, index) =>
374
+ strictifyNode(schema, `${path}.${keyword}[${index}]`, provider),
375
+ );
376
+ }
377
+
378
+ return result;
379
+ }
380
+
381
+ function strictifyObject(node: JsonSchema, path: string, provider?: string): JsonSchema {
382
+ const properties = ownValue(node, "properties") as Record<string, JsonSchema> | undefined;
70
383
  if (!properties) {
71
384
  return { ...node, additionalProperties: false };
72
385
  }
73
386
 
74
- const existingRequired = new Set((node.required as string[] | undefined) ?? []);
387
+ const existingRequired = new Set((ownValue(node, "required") as string[] | undefined) ?? []);
75
388
  const allKeys = Object.keys(properties);
76
389
 
77
- const strictProps: Record<string, JsonSchema> = {};
78
- for (const key of allKeys) {
79
- const prop = properties[key]!;
80
- const strictProp = strictifyNode(prop, `${path}.${key}`);
390
+ const strictProps = Object.fromEntries(
391
+ allKeys.map((key) => {
392
+ const prop = properties[key]!;
393
+ const strictProp = strictifyNode(prop, `${path}.${key}`, provider);
81
394
 
82
- if (!existingRequired.has(key)) {
83
- strictProps[key] = makeNullable(strictProp);
84
- } else {
85
- strictProps[key] = strictProp;
86
- }
87
- }
395
+ return [key, existingRequired.has(key) ? strictProp : makeNullable(strictProp)];
396
+ }),
397
+ ) as Record<string, JsonSchema>;
88
398
 
89
399
  const result: JsonSchema = {
90
400
  ...node,
@@ -97,29 +407,180 @@ function strictifyObject(node: JsonSchema, path: string): JsonSchema {
97
407
  return result;
98
408
  }
99
409
 
100
- function strictifyArray(node: JsonSchema, path: string): JsonSchema {
101
- const items = node.items as JsonSchema | undefined;
102
- if (!items) return { ...node };
410
+ const UNREACHABLE_TUPLE_TAIL_SCHEMA: JsonSchema = { type: "string" };
411
+
412
+ function strictifyArray(node: JsonSchema, path: string, provider?: string): JsonSchema {
413
+ if (
414
+ (provider === "openai" || provider === "anthropic") &&
415
+ (Array.isArray(ownValue(node, "items")) || Array.isArray(ownValue(node, "prefixItems")))
416
+ ) {
417
+ return normalizeTuple(node, path, provider);
418
+ }
419
+
420
+ const result = { ...node };
421
+ const items = ownValue(node, "items") as JsonSchema | JsonSchema[] | undefined;
422
+
423
+ if (Array.isArray(items)) {
424
+ result.items = items.map((schema, index) =>
425
+ strictifyNode(schema, `${path}.items[${index}]`, provider),
426
+ );
427
+ } else if (items !== undefined) {
428
+ result.items = strictifyNode(items, `${path}.items`, provider);
429
+ }
430
+
431
+ const prefixItems = ownValue(node, "prefixItems") as JsonSchema[] | undefined;
432
+ if (Array.isArray(prefixItems)) {
433
+ result.prefixItems = prefixItems.map((schema, index) =>
434
+ strictifyNode(schema, `${path}.prefixItems[${index}]`, provider),
435
+ );
436
+ }
437
+
438
+ return result;
439
+ }
440
+
441
+ function normalizeTuple(
442
+ node: JsonSchema,
443
+ path: string,
444
+ provider: "openai" | "anthropic",
445
+ ): JsonSchema {
446
+ const items = ownValue(node, "items");
447
+ const prefixItems = ownValue(node, "prefixItems");
448
+ const legacyTuple = Array.isArray(items);
449
+ const prefixTuple = Array.isArray(prefixItems);
450
+ if (legacyTuple && prefixTuple) {
451
+ throw new StrictifierError("items and prefixItems tuple forms cannot be combined", path);
452
+ }
453
+ if (prefixTuple && Object.hasOwn(node, "additionalItems")) {
454
+ throw new StrictifierError("additionalItems cannot be combined with prefixItems", path);
455
+ }
103
456
 
457
+ const rawTuple = (legacyTuple ? items : prefixItems) as unknown[];
458
+ const tuplePath = legacyTuple ? `${path}.items` : `${path}.prefixItems`;
459
+ const tuple = rawTuple.map((schema, index) => {
460
+ if (!isSchemaObject(schema)) {
461
+ throw new StrictifierError(
462
+ "boolean and non-object tuple member schemas are not supported",
463
+ `${tuplePath}[${index}]`,
464
+ );
465
+ }
466
+ const memberPath = `${tuplePath}[${index}]`;
467
+ const member = strictifyNode(schema, memberPath, provider);
468
+ if (!hasProviderSchemaEntryPoint(member)) {
469
+ throw new StrictifierError(
470
+ "tuple member must define a provider-representable schema",
471
+ memberPath,
472
+ );
473
+ }
474
+ return member;
475
+ });
476
+
477
+ const rawTail = legacyTuple ? ownValue(node, "additionalItems") : items;
478
+ if (rawTail === true) {
479
+ throw new StrictifierError("an unrestricted tuple rest is not supported", path);
480
+ }
481
+ if (rawTail !== undefined && rawTail !== false && !isSchemaObject(rawTail)) {
482
+ throw new StrictifierError("tuple rest must be an object schema or false", path);
483
+ }
484
+
485
+ const tail = isSchemaObject(rawTail)
486
+ ? strictifyNode(rawTail, legacyTuple ? `${path}.additionalItems` : `${path}.items`, provider)
487
+ : undefined;
488
+ if (tail !== undefined && !hasProviderSchemaEntryPoint(tail)) {
489
+ throw new StrictifierError("an unrestricted tuple rest is not supported", path);
490
+ }
491
+ const tailMode = tail !== undefined ? "typed" : rawTail === false ? "closed" : "implicit";
492
+ const bounds = normalizeTupleBounds(node, tuple.length, tailMode, path);
493
+ const base = omitKeys(node, ["items", "prefixItems", "additionalItems", "minItems", "maxItems"]);
494
+
495
+ // Anthropic 은 assertNormalizationCompatibility 가 비어 있지 않은 tuple 을 이미 거부했으므로
496
+ // 여기 도달하는 tuple 은 빈 tuple 뿐이고, 그 경우 prefixItems spread 는 자연히 생략된다 —
497
+ // provider 분기 없이 한 형태로 두 provider 모두를 표현한다.
104
498
  return {
105
- ...node,
106
- items: strictifyNode(items, `${path}.items`),
499
+ ...base,
500
+ ...(tuple.length > 0 ? { prefixItems: tuple } : {}),
501
+ items: tail ?? UNREACHABLE_TUPLE_TAIL_SCHEMA,
502
+ ...bounds,
503
+ };
504
+ }
505
+
506
+ function normalizeTupleBounds(
507
+ node: JsonSchema,
508
+ tupleLength: number,
509
+ tailMode: "typed" | "closed" | "implicit",
510
+ path: string,
511
+ ): { minItems: number; maxItems?: number } {
512
+ const callerMinItems = integerBound(ownValue(node, "minItems"), "minItems", path);
513
+ const callerMaxItems = integerBound(ownValue(node, "maxItems"), "maxItems", path);
514
+ if (tailMode === "implicit" && callerMaxItems !== undefined && callerMaxItems > tupleLength) {
515
+ throw new StrictifierError(
516
+ "implicit tuple rest cannot be represented when maxItems exceeds the tuple length",
517
+ path,
518
+ );
519
+ }
520
+
521
+ const minItems = callerMinItems ?? (callerMaxItems === undefined ? tupleLength : 0);
522
+ const maxItems =
523
+ tailMode === "typed" ? callerMaxItems : Math.min(callerMaxItems ?? tupleLength, tupleLength);
524
+
525
+ if (maxItems !== undefined && minItems > maxItems) {
526
+ throw new StrictifierError("tuple minItems cannot exceed maxItems", path);
527
+ }
528
+
529
+ return {
530
+ minItems,
531
+ ...(maxItems !== undefined ? { maxItems } : {}),
107
532
  };
108
533
  }
109
534
 
535
+ function integerBound(value: unknown, keyword: string, path: string): number | undefined {
536
+ if (value === undefined) return undefined;
537
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
538
+ throw new StrictifierError(`${keyword} must be a non-negative safe integer`, path);
539
+ }
540
+ return value as number;
541
+ }
542
+
543
+ function isSchemaObject(value: unknown): value is JsonSchema {
544
+ return typeof value === "object" && value !== null && !Array.isArray(value);
545
+ }
546
+
547
+ function ownValue(schema: JsonSchema, keyword: string): unknown {
548
+ return Object.hasOwn(schema, keyword) ? schema[keyword] : undefined;
549
+ }
550
+
551
+ const PROVIDER_SCHEMA_ENTRY_POINT_KEYWORDS = new Set([
552
+ "type",
553
+ "$ref",
554
+ "properties",
555
+ "anyOf",
556
+ "oneOf",
557
+ "allOf",
558
+ ]);
559
+
560
+ function hasProviderSchemaEntryPoint(schema: JsonSchema): boolean {
561
+ return Object.keys(schema).some((key) => PROVIDER_SCHEMA_ENTRY_POINT_KEYWORDS.has(key));
562
+ }
563
+
564
+ function omitKeys(node: JsonSchema, keys: readonly string[]): JsonSchema {
565
+ const omitted = new Set(keys);
566
+ return Object.fromEntries(Object.entries(node).filter(([key]) => !omitted.has(key)));
567
+ }
568
+
110
569
  function makeNullable(schema: JsonSchema): JsonSchema {
111
- if (schema.type === "null") return schema;
570
+ if (ownValue(schema, "type") === "null") return schema;
112
571
 
113
- if (schema.anyOf) {
114
- const variants = schema.anyOf as JsonSchema[];
115
- const hasNull = variants.some((v) => v.type === "null");
572
+ const anyOf = ownValue(schema, "anyOf");
573
+ if (anyOf) {
574
+ const variants = anyOf as JsonSchema[];
575
+ const hasNull = variants.some((v) => ownValue(v, "type") === "null");
116
576
  if (hasNull) return schema;
117
577
  return { anyOf: [...variants, { type: "null" }] };
118
578
  }
119
579
 
120
- if (schema.oneOf) {
121
- const variants = schema.oneOf as JsonSchema[];
122
- const hasNull = variants.some((v) => v.type === "null");
580
+ const oneOf = ownValue(schema, "oneOf");
581
+ if (oneOf) {
582
+ const variants = oneOf as JsonSchema[];
583
+ const hasNull = variants.some((v) => ownValue(v, "type") === "null");
123
584
  if (hasNull) return schema;
124
585
  return { oneOf: [...variants, { type: "null" }] };
125
586
  }
@@ -164,6 +164,37 @@ describe("CodexAppServerWorker active turn cleanup", () => {
164
164
  expect.objectContaining({ message: "codex worker exited while turn was running" }),
165
165
  );
166
166
  });
167
+
168
+ it("rejects the stream promise when the completion callback throws", async () => {
169
+ const { worker, handlers } = createWorkerWithFakeRpc();
170
+ const callbacks: StreamCallbacks = {
171
+ onDelta: vi.fn(),
172
+ onComplete: () => {
173
+ throw new Error("completion mapping failed");
174
+ },
175
+ onError: vi.fn(),
176
+ };
177
+ const promise = (
178
+ worker as unknown as {
179
+ consumeStreamNotifications(
180
+ threadId: string,
181
+ model: string,
182
+ cb: StreamCallbacks,
183
+ ): Promise<void>;
184
+ }
185
+ ).consumeStreamNotifications("thread-1", "gpt-test", callbacks);
186
+
187
+ handlers.get("turn/completed")?.({
188
+ threadId: "thread-1",
189
+ turn: { status: "completed", durationMs: 90 },
190
+ } as never);
191
+
192
+ await expect(promise).rejects.toThrow("completion mapping failed");
193
+ expect(callbacks.onError).not.toHaveBeenCalled();
194
+ expect(
195
+ (worker as unknown as { activeTurnAbort?: (error: Error) => void }).activeTurnAbort,
196
+ ).toBeUndefined();
197
+ });
167
198
  });
168
199
 
169
200
  describe("CodexAppServerWorker TTFT", () => {
@@ -756,8 +756,12 @@ export class CodexAppServerWorker {
756
756
  if (settled) return;
757
757
  settled = true;
758
758
  cleanup();
759
- cb.onError(error);
760
- reject(error);
759
+ try {
760
+ cb.onError(error);
761
+ reject(error);
762
+ } catch (callbackError) {
763
+ reject(callbackError);
764
+ }
761
765
  };
762
766
 
763
767
  const finishWithComplete = (result: TurnResult) => {
@@ -765,8 +769,12 @@ export class CodexAppServerWorker {
765
769
  settled = true;
766
770
  cleanup();
767
771
  if (result.imageAttempted) this.cleanupGeneratedImages();
768
- cb.onComplete(result);
769
- resolve();
772
+ try {
773
+ cb.onComplete(result);
774
+ resolve();
775
+ } catch (callbackError) {
776
+ reject(callbackError);
777
+ }
770
778
  };
771
779
 
772
780
  abort = finishWithError;