@cartanova/qgrid-cli 2.5.5 → 2.5.7

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 (42) hide show
  1. package/README.ko.md +0 -1
  2. package/README.md +0 -1
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +7 -7
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +1 -8
  5. package/bundle/dist/application/qgrid/qgrid.types.js +2 -15
  6. package/bundle/dist/application/qgrid/tool-emulation-schema.js +39 -19
  7. package/bundle/dist/application/qgrid/tool-emulation.js +31 -44
  8. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -7
  9. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +4 -5
  10. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +22 -8
  11. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +8 -8
  12. package/bundle/src/application/qgrid/qgrid.frame.test.ts +2 -17
  13. package/bundle/src/application/qgrid/qgrid.frame.ts +0 -10
  14. package/bundle/src/application/qgrid/qgrid.types.ts +23 -45
  15. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +35 -15
  16. package/bundle/src/application/qgrid/tool-emulation-schema.ts +49 -32
  17. package/bundle/src/application/qgrid/tool-emulation.test.ts +92 -144
  18. package/bundle/src/application/qgrid/tool-emulation.ts +64 -76
  19. package/bundle/src/utils/providers/openai/codex-worker.test.ts +147 -0
  20. package/bundle/src/utils/providers/openai/codex-worker.ts +29 -5
  21. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +13 -4
  22. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +3 -9
  23. package/bundle/web-dist/client/assets/index-B-cZKEyf.css +1 -0
  24. package/bundle/web-dist/client/assets/{index-CN_kMswh.js → index-Bm7RpZcx.js} +3 -3
  25. package/bundle/web-dist/client/assets/{logs-6nYj9OKL.js → logs-BrJdk9XJ.js} +1 -1
  26. package/bundle/web-dist/client/assets/{monit-CaPH9Uwc.js → monit-BuFCoBCr.js} +1 -1
  27. package/bundle/web-dist/client/assets/{routes-Dh5HPi-b.js → routes-d-Fwxdg8.js} +1 -1
  28. package/bundle/web-dist/client/assets/{show-Be_LVd9d.js → show-Z5UX7FtU.js} +1 -1
  29. package/bundle/web-dist/client/assets/{tokens-CAi3zu_0.js → tokens-OA2V76xd.js} +1 -1
  30. package/bundle/web-dist/client/index.html +2 -2
  31. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  32. package/package.json +1 -1
  33. package/skills/qgrid/SKILL.md +1 -1
  34. package/skills/qgrid/references/ai-sdk-provider-contract.md +1 -1
  35. package/skills/qgrid/references/cli-env-and-server-boot.md +2 -3
  36. package/skills/qgrid/references/decision-rationale.md +1 -1
  37. package/skills/qgrid/references/openai-codex-runtime.md +6 -3
  38. package/skills/qgrid/references/repo-map.md +1 -1
  39. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  40. package/skills/qgrid/references/tool-calling-and-multiturn.md +44 -26
  41. package/skills/qgrid/references/verification-and-debugging.md +2 -2
  42. package/bundle/web-dist/client/assets/index-yZcjDoV0.css +0 -1
@@ -100,40 +100,29 @@ export const QgridToolResultInput = z.object({
100
100
  });
101
101
  export type QgridToolResultInput = z.infer<typeof QgridToolResultInput>;
102
102
 
103
- export const QueryInput = z
104
- .looseObject({
105
- system: z.string().optional(),
106
- prompt: z.string(),
107
- input: z.array(QgridInputPart).optional(),
108
- model: z.string().optional(),
109
- timeout: QueryTimeoutMs.optional(),
110
- jsonSchema: z.string().optional(),
111
- tools: z.array(QgridTool).optional(),
112
- effort: z.string().optional(),
113
- verbosity: Verbosity.optional(),
114
- reasoningSummary: ReasoningSummary.optional(),
115
- serviceTier: ServiceTier.optional(),
116
- history: z.string().optional(),
117
- projectName: z.string().optional(),
118
- // 호환용 필드. 서버 lifecycle 판단에는 사용하지 않는다.
119
- isStep: z.boolean().optional(),
120
- // 생략하면 로깅한다. false는 request log 쓰기만 끄고 dispatch/thread는 유지한다.
121
- logger: z.boolean().optional(),
122
- runContext: QgridRunContext.optional(),
123
- toolResults: z.array(QgridToolResultInput).optional(),
124
- // codex 내장 image_generation tool 을 켠다(OpenAI 경로 전용, opt-in, non-stream).
125
- imageGeneration: z.boolean().optional(),
126
- // qgrid 가격 추정 및 Codex 이미지 요청 힌트. 이미지 모델은 gpt-image-2 로 고정 가정한다.
127
- imageGenerationOptions: ImageGenerationOptions.optional(),
128
- })
129
- .superRefine((value, ctx) => {
130
- if (!Object.hasOwn(value, "logMode")) return;
131
- ctx.addIssue({
132
- code: "custom",
133
- path: ["logMode"],
134
- message: "logMode is no longer supported; use logger: false to disable request logging",
135
- });
136
- });
103
+ export const QueryInput = z.looseObject({
104
+ system: z.string().optional(),
105
+ prompt: z.string(),
106
+ input: z.array(QgridInputPart).optional(),
107
+ model: z.string().optional(),
108
+ timeout: QueryTimeoutMs.optional(),
109
+ jsonSchema: z.string().optional(),
110
+ tools: z.array(QgridTool).optional(),
111
+ effort: z.string().optional(),
112
+ verbosity: Verbosity.optional(),
113
+ reasoningSummary: ReasoningSummary.optional(),
114
+ serviceTier: ServiceTier.optional(),
115
+ history: z.string().optional(),
116
+ projectName: z.string().optional(),
117
+ // 생략하면 로깅한다. false는 request log 쓰기만 끄고 dispatch/thread는 유지한다.
118
+ logger: z.boolean().optional(),
119
+ runContext: QgridRunContext.optional(),
120
+ toolResults: z.array(QgridToolResultInput).optional(),
121
+ // codex 내장 image_generation tool 을 켠다(OpenAI 경로 전용, opt-in, non-stream).
122
+ imageGeneration: z.boolean().optional(),
123
+ // qgrid 가격 추정 및 Codex 이미지 요청 힌트. 이미지 모델은 gpt-image-2 로 고정 가정한다.
124
+ imageGenerationOptions: ImageGenerationOptions.optional(),
125
+ });
137
126
  export type QueryInput = z.infer<typeof QueryInput>;
138
127
 
139
128
  export const ModelFallback = z.object({
@@ -273,17 +262,6 @@ export type FinishRunInput = z.infer<typeof FinishRunInput>;
273
262
 
274
263
  // ─── Token Management ───
275
264
 
276
- export const AddTokenInput = z.object({
277
- token: z.string(),
278
- name: z.string(),
279
- });
280
- export type AddTokenInput = z.infer<typeof AddTokenInput>;
281
-
282
- export const RemoveTokenInput = z.object({
283
- token: z.string(),
284
- });
285
- export type RemoveTokenInput = z.infer<typeof RemoveTokenInput>;
286
-
287
265
  export const TokenStats = z.object({
288
266
  token: z.string(),
289
267
  name: z.string(),
@@ -31,27 +31,48 @@ describe("buildToolCallSchema", () => {
31
31
  expect.stringContaining("Do not invoke listed client tools as native Claude Code tools"),
32
32
  );
33
33
  expect(schema).toHaveProperty(
34
- ["properties", "action", "description"],
35
- expect.stringContaining("tool_call"),
36
- );
37
- expect(schema).toHaveProperty(
38
- ["properties", "toolCalls", "description"],
34
+ ["properties", "result", "anyOf", 1, "description"],
39
35
  expect.stringContaining("Do not call these tools as native"),
40
36
  );
41
37
  expect(schema).toHaveProperty(
42
- ["properties", "toolCalls", "anyOf", 0, "items", "properties", "toolName", "description"],
38
+ [
39
+ "properties",
40
+ "result",
41
+ "anyOf",
42
+ 1,
43
+ "properties",
44
+ "toolCalls",
45
+ "items",
46
+ "properties",
47
+ "toolName",
48
+ "description",
49
+ ],
43
50
  expect.stringContaining("getWeather"),
44
51
  );
45
52
  });
46
53
 
47
- it("preserves the legacy string answer shape when no user schema is supplied", () => {
54
+ it("makes degenerate combinations grammatically impossible", () => {
48
55
  const schema = buildToolCallSchema(tools);
49
56
 
50
57
  expect(schema).not.toHaveProperty("$defs");
51
- expect(schema).toHaveProperty("properties.answer.anyOf", [
52
- { type: "string" },
53
- { type: "null" },
54
- ]);
58
+ // answer 변형: answer 는 비 null string 필수, toolCalls 는 null 고정.
59
+ expect(schema).toHaveProperty(["properties", "result", "anyOf", 0, "properties", "answer"], {
60
+ type: "string",
61
+ });
62
+ expect(schema).toHaveProperty(["properties", "result", "anyOf", 0, "properties", "toolCalls"], {
63
+ type: "null",
64
+ });
65
+ // tool_call 변형: toolCalls 1개 이상 필수, answer 는 null 고정.
66
+ expect(schema).toHaveProperty(
67
+ ["properties", "result", "anyOf", 1, "properties", "toolCalls", "minItems"],
68
+ 1,
69
+ );
70
+ expect(schema).toHaveProperty(["properties", "result", "anyOf", 1, "properties", "answer"], {
71
+ type: "null",
72
+ });
73
+ // OpenAI structured outputs 제약: 루트는 object, union 은 result property 아래 중첩.
74
+ expect(schema).toHaveProperty("type", "object");
75
+ expect(schema).toHaveProperty("required", ["result"]);
55
76
  });
56
77
 
57
78
  it("namespaces a user schema and references it from answer", () => {
@@ -61,10 +82,9 @@ describe("buildToolCallSchema", () => {
61
82
  };
62
83
  const schema = buildToolCallSchema(tools, userSchema);
63
84
 
64
- expect(schema).toHaveProperty("properties.answer.anyOf", [
65
- { $ref: "#/$defs/__qgrid_user_output" },
66
- { type: "null" },
67
- ]);
85
+ expect(schema).toHaveProperty(["properties", "result", "anyOf", 0, "properties", "answer"], {
86
+ $ref: "#/$defs/__qgrid_user_output",
87
+ });
68
88
  expect(schema).toHaveProperty(userPath(), userSchema);
69
89
  });
70
90
 
@@ -34,6 +34,12 @@ export class ToolSchemaCompositionError extends Error {
34
34
  }
35
35
  }
36
36
 
37
+ // envelope 는 result 한 겹 아래에 discriminated union 을 둔다. 두 변형을 문법 수준에서
38
+ // 상호배타로 만들어(answer 변형은 answer 비null 필수, tool_call 변형은 toolCalls 1개 이상)
39
+ // `action:"answer", answer:null` 같은 퇴화 조합을 원천 차단한다 — 평평한 스키마 시절
40
+ // 이 조합이 constrained decoding 을 통과해 13.5k 건의 오염 응답을 만든 실사고가 근거.
41
+ // union 을 result property 안에 중첩하는 이유: OpenAI structured outputs 는 루트가
42
+ // object 여야 하고 top-level anyOf 를 거부한다. 중첩 anyOf 와 minItems 는 지원된다.
37
43
  export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue): JsonValue {
38
44
  const toolDescriptions = tools
39
45
  .map((tool) => {
@@ -48,48 +54,59 @@ export function buildToolCallSchema(tools: QgridTool[], answerSchema?: JsonValue
48
54
  return {
49
55
  type: "object",
50
56
  description:
51
- "Use this schema only through StructuredOutput. Do not invoke listed client tools as native Claude Code tools. To request client-side tool execution, set action to tool_call and include toolCalls. Use action answer only when no further client tool result is needed.",
57
+ "Use this schema only through StructuredOutput. Do not invoke listed client tools as native Claude Code tools. To request client-side tool execution, set result.action to tool_call and include result.toolCalls. Use action answer only when no further client tool result is needed.",
52
58
  properties: {
53
- action: {
54
- type: "string",
55
- enum: ["answer", "tool_call"],
56
- description:
57
- "Use tool_call to request client-side tool execution through this structured output. Use answer only for a final answer.",
58
- },
59
- answer: {
60
- anyOf: [
61
- normalizedAnswerSchema === undefined ? { type: "string" } : { $ref: USER_OUTPUT_REF },
62
- { type: "null" },
63
- ],
64
- },
65
- toolCalls: {
66
- description:
67
- "Client-side tool calls requested through structured output. Do not call these tools as native Claude Code tools.",
59
+ result: {
68
60
  anyOf: [
69
61
  {
70
- type: "array",
71
- items: {
72
- type: "object",
73
- properties: {
74
- toolName: {
75
- type: "string",
76
- enum: tools.map((tool) => tool.name),
77
- description: `Client-side tool name to request through structured output. Do not call this as a native Claude Code tool.\n${toolDescriptions}`,
78
- },
79
- args: {
80
- type: "string",
81
- description: "Tool arguments as a JSON string for the client-side tool.",
62
+ type: "object",
63
+ description: "Final answer. Use only when no further client tool result is needed.",
64
+ properties: {
65
+ action: { type: "string", enum: ["answer"] },
66
+ answer:
67
+ normalizedAnswerSchema === undefined
68
+ ? { type: "string" }
69
+ : { $ref: USER_OUTPUT_REF },
70
+ toolCalls: { type: "null" },
71
+ },
72
+ required: ["action", "answer", "toolCalls"],
73
+ additionalProperties: false,
74
+ },
75
+ {
76
+ type: "object",
77
+ description:
78
+ "Request client-side tool execution through this structured output. Do not call these tools as native Claude Code tools.",
79
+ properties: {
80
+ action: { type: "string", enum: ["tool_call"] },
81
+ answer: { type: "null" },
82
+ toolCalls: {
83
+ type: "array",
84
+ minItems: 1,
85
+ items: {
86
+ type: "object",
87
+ properties: {
88
+ toolName: {
89
+ type: "string",
90
+ enum: tools.map((tool) => tool.name),
91
+ description: `Client-side tool name to request through structured output. Do not call this as a native Claude Code tool.\n${toolDescriptions}`,
92
+ },
93
+ args: {
94
+ type: "string",
95
+ description: "Tool arguments as a JSON string for the client-side tool.",
96
+ },
97
+ },
98
+ required: ["toolName", "args"],
99
+ additionalProperties: false,
82
100
  },
83
101
  },
84
- required: ["toolName", "args"],
85
- additionalProperties: false,
86
102
  },
103
+ required: ["action", "answer", "toolCalls"],
104
+ additionalProperties: false,
87
105
  },
88
- { type: "null" },
89
106
  ],
90
107
  },
91
108
  },
92
- required: ["action", "answer", "toolCalls"],
109
+ required: ["result"],
93
110
  additionalProperties: false,
94
111
  ...(normalizedAnswerSchema === undefined
95
112
  ? {}
@@ -24,11 +24,10 @@ describe("applyToolCallEmulation image parts", () => {
24
24
  const img = { data: "iVBORw0KGgoBAgM", revisedPrompt: "a red circle" };
25
25
 
26
26
  it("appends an image part after text when images are present (no tools)", () => {
27
- const out = applyToolCallEmulation(
28
- { ...baseResult, text: "here is your image" },
29
- undefined,
30
- { images: [img] },
31
- );
27
+ const out = applyToolCallEmulation({ ...baseResult, text: "here is your image" }, undefined, {
28
+ images: [img],
29
+ answerKind: "text",
30
+ });
32
31
  expect(out.content).toEqual([
33
32
  { type: "text", text: "here is your image" },
34
33
  { type: "image", data: "iVBORw0KGgoBAgM", revisedPrompt: "a red circle" },
@@ -38,23 +37,22 @@ describe("applyToolCallEmulation image parts", () => {
38
37
  });
39
38
 
40
39
  it("appends multiple images preserving order", () => {
41
- const out = applyToolCallEmulation(
42
- { ...baseResult, text: "here is your image" },
43
- undefined,
44
- {
45
- images: [
46
- { data: "iVBORw0KGgoAAA", revisedPrompt: "one" },
47
- { data: "iVBORw0KGgoBBB", revisedPrompt: "two" },
48
- ],
49
- },
50
- );
40
+ const out = applyToolCallEmulation({ ...baseResult, text: "here is your image" }, undefined, {
41
+ answerKind: "text",
42
+ images: [
43
+ { data: "iVBORw0KGgoAAA", revisedPrompt: "one" },
44
+ { data: "iVBORw0KGgoBBB", revisedPrompt: "two" },
45
+ ],
46
+ });
51
47
  const images = out.content.filter((c) => c.type === "image");
52
48
  expect(images).toHaveLength(2);
53
49
  expect(images.map((c) => (c.type === "image" ? c.revisedPrompt : null))).toEqual(["one", "two"]);
54
50
  });
55
51
 
56
52
  it("leaves content unchanged when no images are passed (regression guard)", () => {
57
- const out = applyToolCallEmulation({ ...baseResult, text: "here is your image" });
53
+ const out = applyToolCallEmulation({ ...baseResult, text: "here is your image" }, undefined, {
54
+ answerKind: "text",
55
+ });
58
56
  expect(out.content).toEqual([{ type: "text", text: "here is your image" }]);
59
57
  });
60
58
  });
@@ -71,22 +69,14 @@ describe("applyToolCallEmulation envelope validation", () => {
71
69
  },
72
70
  ];
73
71
 
74
- function applyEnvelope(
75
- envelope: unknown,
76
- answerMode: "legacy" | "structured" = "structured",
77
- ) {
78
- return applyToolCallEmulation(
79
- { ...baseResult, text: JSON.stringify(envelope) },
80
- tools,
81
- { answerMode },
82
- );
72
+ function applyEnvelope(result: unknown, answerKind: "text" | "json" = "json") {
73
+ return applyToolCallEmulation({ ...baseResult, text: JSON.stringify({ result }) }, tools, {
74
+ answerKind,
75
+ });
83
76
  }
84
77
 
85
- it("preserves a legacy tools-only string answer", () => {
86
- const out = applyEnvelope(
87
- { action: "answer", answer: "done", toolCalls: null },
88
- "legacy",
89
- );
78
+ it("returns a text answer verbatim without JSON quoting", () => {
79
+ const out = applyEnvelope({ action: "answer", answer: "done", toolCalls: null }, "text");
90
80
 
91
81
  expect(out.text).toBe("done");
92
82
  expect(out.content).toEqual([{ type: "text", text: "done" }]);
@@ -99,7 +89,7 @@ describe("applyToolCallEmulation envelope validation", () => {
99
89
  ["scalar", '"scalar"'],
100
90
  [42, "42"],
101
91
  [false, "false"],
102
- ])("serializes structured answer %j as JSON text", (answer, expected) => {
92
+ ])("serializes json answer %j as JSON text", (answer, expected) => {
103
93
  const out = applyEnvelope({ action: "answer", answer, toolCalls: null });
104
94
 
105
95
  expect(out.text).toBe(expected);
@@ -107,61 +97,17 @@ describe("applyToolCallEmulation envelope validation", () => {
107
97
  expect(out.finishReason).toBe("stop");
108
98
  });
109
99
 
110
- it("preserves prototype-sensitive keys in a structured answer", () => {
100
+ it("preserves prototype-sensitive keys in a json answer", () => {
111
101
  const out = applyToolCallEmulation(
112
102
  {
113
103
  ...baseResult,
114
- text: String.raw`{"action":"answer","answer":{"__proto__":"kept","nested":{"__proto__":1}},"toolCalls":null}`,
104
+ text: String.raw`{"result":{"action":"answer","answer":{"__proto__":"kept","nested":{"__proto__":1}},"toolCalls":null}}`,
115
105
  },
116
106
  tools,
117
- { answerMode: "structured" },
107
+ { answerKind: "json" },
118
108
  );
119
109
 
120
- expect(out.text).toBe(
121
- String.raw`{"__proto__":"kept","nested":{"__proto__":1}}`,
122
- );
123
- });
124
-
125
- it("accepts null or empty toolCalls on a structured answer branch", () => {
126
- for (const envelope of [
127
- { action: "answer", answer: { ok: true }, toolCalls: null },
128
- { action: "answer", answer: { ok: true }, toolCalls: [] },
129
- ]) {
130
- expect(applyEnvelope(envelope).text).toBe('{"ok":true}');
131
- }
132
- });
133
-
134
- it("keeps absent toolCalls compatible on a legacy answer branch", () => {
135
- expect(applyEnvelope({ action: "answer", answer: "done" }, "legacy").text).toBe("done");
136
- });
137
-
138
- it.each([
139
- [{ action: "answer", answer: null, toolCalls: null }, undefined],
140
- [{ action: "answer", toolCalls: [] }, undefined],
141
- [{ action: "unexpected", answer: "fallback answer", toolCalls: null }, "fallback answer"],
142
- [{ answer: "", toolCalls: null }, ""],
143
- [42, undefined],
144
- ])("preserves tolerant legacy final-answer fallback for %j", (envelope, expected) => {
145
- const outerText = JSON.stringify(envelope);
146
- const out = applyToolCallEmulation(
147
- { ...baseResult, text: outerText },
148
- tools,
149
- { answerMode: "legacy" },
150
- );
151
-
152
- expect(out.text).toBe(expected ?? outerText);
153
- expect(out.finishReason).toBe("stop");
154
- });
155
-
156
- it.each([
157
- { action: "tool_call", answer: null, toolCalls: null },
158
- { action: "tool_call", answer: null },
159
- { action: "tool_call", answer: null, toolCalls: [] },
160
- ])("preserves tolerant legacy empty tool_call envelopes: %j", (envelope) => {
161
- const out = applyEnvelope(envelope, "legacy");
162
-
163
- expect(out.content).toEqual([]);
164
- expect(out.finishReason).toBe("tool-calls");
110
+ expect(out.text).toBe(String.raw`{"__proto__":"kept","nested":{"__proto__":1}}`);
165
111
  });
166
112
 
167
113
  it("maps a coherent non-empty tool_call branch", () => {
@@ -181,56 +127,70 @@ describe("applyToolCallEmulation envelope validation", () => {
181
127
  expect(out.finishReason).toBe("tool-calls");
182
128
  });
183
129
 
130
+ // 퇴화 조합 전수 거부 — 과거 관용 디코더가 이들을 조용히 통과시켜 envelope 원문이
131
+ // 최종 답변으로 흘러나간 실사고(2026-07 medpath 13.5k 건)가 있었다. 관용 폴백 금지.
184
132
  it.each([
185
- { action: "answer", answer: null, toolCalls: null },
186
- { action: "answer", answer: "done", toolCalls: [{}] },
187
- { action: "tool_call", answer: "mixed", toolCalls: [{}] },
188
- { action: "tool_call", answer: null, toolCalls: [] },
189
- { action: "tool_call", answer: null, toolCalls: [{ toolName: "lookup" }] },
190
- { action: "unexpected", answer: "done", toolCalls: null },
191
- ["not", "an", "object"],
192
- { action: "answer", answer: { ok: true } },
193
- { action: "answer", answer: { ok: true }, toolCalls: null, extra: true },
194
- {
195
- action: "tool_call",
196
- answer: null,
197
- toolCalls: [{ toolName: "lookup", args: "{}", extra: true }],
198
- },
199
- ])("rejects incoherent structured envelope %j", (envelope) => {
200
- expect(() => applyEnvelope(envelope)).toThrowError(ToolCallEmulationError);
201
- });
202
-
203
- it("rejects unknown tools after decoding a structured tool call", () => {
204
- expect(() =>
205
- applyEnvelope({
133
+ [{ action: "answer", answer: null, toolCalls: null }],
134
+ [{ action: "answer", toolCalls: [] }],
135
+ [{ action: "answer", answer: "done", toolCalls: [] }],
136
+ [{ action: "unexpected", answer: "done", toolCalls: null }],
137
+ [{ answer: "done", toolCalls: null }],
138
+ [{ action: "tool_call", answer: null, toolCalls: null }],
139
+ [{ action: "tool_call", answer: null, toolCalls: [] }],
140
+ [{ action: "tool_call", answer: "mixed", toolCalls: [{ toolName: "lookup", args: "{}" }] }],
141
+ [{ action: "tool_call", answer: null, toolCalls: [{ toolName: "lookup" }] }],
142
+ [{ action: "answer", answer: { ok: true }, toolCalls: null, extra: true }],
143
+ [
144
+ {
206
145
  action: "tool_call",
207
146
  answer: null,
208
- toolCalls: [{ toolName: "missing", args: "{}" }],
209
- }),
210
- ).toThrowError(/unknown emulated tool/);
147
+ toolCalls: [{ toolName: "lookup", args: "{}", extra: true }],
148
+ },
149
+ ],
150
+ [42],
151
+ [["not", "an", "object"]],
152
+ ])("rejects degenerate envelope %j in json mode", (result) => {
153
+ expect(() => applyEnvelope(result)).toThrowError(ToolCallEmulationError);
211
154
  });
212
155
 
213
- it("does not apply the structured decoder to legacy responses", () => {
214
- const answer = { ok: true };
215
- const out = applyEnvelope(
216
- { action: "answer", answer, toolCalls: [{ ignored: true }] },
217
- "legacy",
218
- );
219
-
220
- expect(out.text).toEqual(answer);
156
+ it.each([
157
+ [{ action: "answer", answer: null, toolCalls: null }],
158
+ [{ action: "answer", answer: { ok: true }, toolCalls: null }],
159
+ [{ action: "tool_call", answer: null, toolCalls: [] }],
160
+ ])("rejects degenerate envelope %j in text mode too", (result) => {
161
+ expect(() => applyEnvelope(result, "text")).toThrowError(ToolCallEmulationError);
221
162
  });
222
163
 
223
- it("fails malformed outer JSON explicitly in structured mode", () => {
164
+ it("rejects an envelope missing the result wrapper", () => {
224
165
  expect(() =>
225
166
  applyToolCallEmulation(
226
- { ...baseResult, text: "not-json" },
167
+ { ...baseResult, text: '{"action":"answer","answer":"done","toolCalls":null}' },
227
168
  tools,
228
- { answerMode: "structured" },
169
+ { answerKind: "text" },
229
170
  ),
230
171
  ).toThrowError(ToolCallEmulationError);
231
172
  });
232
173
 
233
- it("rejects a deeply nested structured envelope without overflowing the stack", () => {
174
+ it("rejects unknown tools after decoding a tool call", () => {
175
+ expect(() =>
176
+ applyEnvelope({
177
+ action: "tool_call",
178
+ answer: null,
179
+ toolCalls: [{ toolName: "missing", args: "{}" }],
180
+ }),
181
+ ).toThrowError(/unknown emulated tool/);
182
+ });
183
+
184
+ it.each(["text", "json"] as const)(
185
+ "fails malformed outer JSON explicitly in %s mode (no text rescue)",
186
+ (answerKind) => {
187
+ expect(() =>
188
+ applyToolCallEmulation({ ...baseResult, text: "not-json" }, tools, { answerKind }),
189
+ ).toThrowError(ToolCallEmulationError);
190
+ },
191
+ );
192
+
193
+ it("rejects a deeply nested envelope without overflowing the stack", () => {
234
194
  let answer: unknown = 0;
235
195
  for (let depth = 0; depth < CALLER_SCHEMA_LIMITS.maxDepth + 5; depth += 1) {
236
196
  answer = [answer];
@@ -238,7 +198,7 @@ describe("applyToolCallEmulation envelope validation", () => {
238
198
 
239
199
  try {
240
200
  applyEnvelope({ action: "answer", answer, toolCalls: null });
241
- throw new Error("expected the structured envelope to be rejected");
201
+ throw new Error("expected the envelope to be rejected");
242
202
  } catch (error) {
243
203
  expect(error).toBeInstanceOf(ToolCallEmulationError);
244
204
  expect(error).not.toBeInstanceOf(RangeError);
@@ -246,50 +206,40 @@ describe("applyToolCallEmulation envelope validation", () => {
246
206
  }
247
207
  });
248
208
 
249
- it("rejects structured envelopes above the node budget", () => {
209
+ it("rejects envelopes above the node budget", () => {
250
210
  expect(() =>
251
211
  applyEnvelope({
252
212
  action: "answer",
253
213
  answer: Array.from({ length: CALLER_SCHEMA_LIMITS.maxNodes }, () => 0),
254
214
  toolCalls: null,
255
215
  }),
256
- ).toThrowError(/structured-output envelope exceeds node limit/);
216
+ ).toThrowError(/exceeds node limit/);
257
217
  });
258
218
 
259
- it("rejects structured envelopes above the UTF-8 byte budget before parsing", () => {
219
+ it("rejects envelopes above the UTF-8 byte budget before parsing", () => {
260
220
  const text = JSON.stringify({
261
- action: "answer",
262
- answer: "x".repeat(CALLER_SCHEMA_LIMITS.maxUtf8Bytes),
263
- toolCalls: null,
221
+ result: {
222
+ action: "answer",
223
+ answer: "x".repeat(CALLER_SCHEMA_LIMITS.maxUtf8Bytes),
224
+ toolCalls: null,
225
+ },
264
226
  });
265
227
 
266
228
  expect(() =>
267
- applyToolCallEmulation(
268
- { ...baseResult, text },
269
- tools,
270
- { answerMode: "structured" },
271
- ),
272
- ).toThrowError(/structured-output envelope exceeds UTF-8 byte limit/);
273
- });
274
-
275
- it("preserves malformed outer JSON fallback in legacy mode", () => {
276
- const out = applyToolCallEmulation({ ...baseResult, text: "not-json" }, tools);
277
-
278
- expect(out.text).toBe("not-json");
279
- expect(out.content).toEqual([{ type: "text", text: "not-json" }]);
280
- expect(out.finishReason).toBe("stop");
229
+ applyToolCallEmulation({ ...baseResult, text }, tools, { answerKind: "json" }),
230
+ ).toThrowError(/exceeds UTF-8 byte limit/);
281
231
  });
282
232
 
283
- it("preserves images after a structured final answer", () => {
233
+ it("preserves images after a json final answer", () => {
284
234
  const out = applyToolCallEmulation(
285
235
  {
286
236
  ...baseResult,
287
- text: '{"action":"answer","answer":{"ok":true},"toolCalls":null}',
237
+ text: '{"result":{"action":"answer","answer":{"ok":true},"toolCalls":null}}',
288
238
  },
289
239
  tools,
290
240
  {
291
241
  images: [{ data: "image-data", revisedPrompt: "revised" }],
292
- answerMode: "structured",
242
+ answerKind: "json",
293
243
  },
294
244
  );
295
245
 
@@ -300,13 +250,11 @@ describe("applyToolCallEmulation envelope validation", () => {
300
250
  });
301
251
 
302
252
  it.each([
303
- '{"action":"answer","answer":1e400,"toolCalls":null}',
304
- '{"action":"answer","answer":{"nested":1e400},"toolCalls":null}',
253
+ '{"result":{"action":"answer","answer":1e400,"toolCalls":null}}',
254
+ '{"result":{"action":"answer","answer":{"nested":1e400},"toolCalls":null}}',
305
255
  ])("rejects non-finite JSON numbers: %s", (text) => {
306
256
  expect(() =>
307
- applyToolCallEmulation({ ...baseResult, text }, tools, {
308
- answerMode: "structured",
309
- }),
257
+ applyToolCallEmulation({ ...baseResult, text }, tools, { answerKind: "json" }),
310
258
  ).toThrowError(/answer must be valid JSON/);
311
259
  });
312
260
  });