@cartanova/qgrid-cli 2.3.2 → 2.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 (26) hide show
  1. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +94 -278
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
  3. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
  4. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +42 -98
  5. package/bundle/dist/utils/providers/anthropic/claude-session.js +54 -32
  6. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +7 -37
  7. package/bundle/dist/utils/providers/common/model-cost.js +3 -2
  8. package/bundle/dist/utils/providers/openai/codex-worker.js +1 -2
  9. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +19 -12
  10. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +235 -7
  11. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +152 -364
  12. package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
  13. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
  14. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
  15. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
  16. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +61 -188
  17. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +89 -62
  18. package/bundle/src/utils/providers/anthropic/claude-session.ts +71 -46
  19. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +74 -30
  20. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +40 -102
  21. package/bundle/src/utils/providers/common/model-cost.test.ts +19 -1
  22. package/bundle/src/utils/providers/common/model-cost.ts +2 -1
  23. package/bundle/src/utils/providers/common/provider-dispatcher.ts +7 -4
  24. package/bundle/src/utils/providers/openai/codex-worker.ts +2 -8
  25. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +18 -11
  26. package/package.json +1 -1
@@ -1,27 +1,21 @@
1
1
  /**
2
2
  * claude-session — Claude CLI 세션 spawn + QgridThreadCoord 매핑 + per-session 락.
3
3
  *
4
- * 멀티턴: 호출 `--session-id <uuid>`, 후속 `--resume <uuid>`. 입력은 U2 어댑터가 만든
5
- * JSONL 을 stdin 으로 흘리고(`--input-format stream-json`), 출력은 U3 파서로 처리한다.
4
+ * 멀티턴: 호출 fresh `--session-id <uuid>`로 실행한다. 입력은 어댑터가 만든 JSONL 을
5
+ * stdin 으로 흘리고(`--input-format stream-json`), 출력은 파서로 처리한다.
6
6
  *
7
7
  * 핵심 계약:
8
8
  * - coord 는 임의 저장이 아니라 기존 QgridThreadCoord{workerId,threadId,epoch,systemHash} 에
9
9
  * 매핑한다. threadId=세션 uuid, epoch=0 고정(worker restart 개념 없음), workerId=token 기반
10
10
  * 안정 합성, systemHash=요청 system 해시. issueConvContext/decideConvRouting 과 round-trip.
11
- * - resume eligibility systemHash 만으로 부족 확장 호환키(system+model+structured 모드)로
12
- * 판정. 불일치면 cold fallback.
13
- * - 같은 세션의 동시 turn 은 per-session 락(session-id 단위 직렬화)으로 transcript 오염 방지.
11
+ * - withSessionLock exported primitive 유지한다. 기본 경로에서는 호출 새 session-id 라
12
+ * 사실상 no-contention 이지만, 직접 호출자/테스트의 직렬화 보장은 보존한다.
14
13
  * - stream-json 직렬화 시 SDK envelope(uuid/session_id/parent_tool_use_id) decorate.
15
14
  * - structured output 일 때 출력 파서에 { structuredOutput: true } 전달(없으면 streamObject 깨짐).
16
- *
17
- * ⚠️ 첫 호출 동시성: resume 가 아닌 "첫 호출"은 여기서 새 랜덤 session-id 를 발급하므로, 같은
18
- * qgrid sessionKey 에 대한 두 동시 첫 호출은 서로 다른 id 로 둘 다 실행된다. 현재 IF/owner scope 는
19
- * LLM 호출을 순차 실행하고 sessionKey 를 서버로 보내지 않으므로, dispatcher 는 cold 락을 두지 않는다.
20
- * 병렬 첫 호출을 서버가 지원하게 되면 sessionKey 전달/락 설계부터 다시 잡아야 한다.
21
15
  */
22
16
 
23
17
  import { spawn } from "node:child_process";
24
- import { createHash, randomUUID } from "node:crypto";
18
+ import { randomUUID } from "node:crypto";
25
19
  import { mkdirSync, writeFileSync } from "node:fs";
26
20
 
27
21
  import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
@@ -32,7 +26,10 @@ import {
32
26
  anthropicConfigDir,
33
27
  ANTHROPIC_DEFAULT_EFFORT,
34
28
  ANTHROPIC_DISALLOWED_TOOLS,
29
+ assertSupportedOneMillionSuffix,
35
30
  canonicalAnthropicModel,
31
+ needsCli1mSuffix,
32
+ supports1MContext,
36
33
  } from "./anthropic-constants";
37
34
  import {
38
35
  buildStreamJsonInput,
@@ -42,26 +39,10 @@ import {
42
39
  handleStreamJsonLine,
43
40
  } from "./stream-json-adapter";
44
41
 
45
- // ── 호환키 ──────────────────────────────────────────────────────────
46
-
47
- // resume 가능 여부를 가르는 키. systemHash 만으로는 같은 sessionKey 를 다른 model/schema 로
48
- // 재사용할 때 context 오염 → system + model + structured 모드까지 묶는다.
49
- export function compatibilityKey(opts: {
50
- system?: string;
51
- model: string;
52
- // structured output schema 문자열. 없으면 text 모드. 내용이 다르면 다른 키.
53
- outputSchema?: string;
54
- }): string {
55
- const schemaPart = opts.outputSchema && opts.outputSchema.length > 0 ? opts.outputSchema : "text";
56
- return createHash("sha256")
57
- .update(`${opts.system ?? ""}\0${opts.model}\0${schemaPart}`)
58
- .digest("hex")
59
- .slice(0, 16);
60
- }
61
-
62
42
  // ── per-session 락 ─────────────────────────────────────────────────
63
43
 
64
- // session-id 단위 직렬화. 같은 세션의 동시 turn 같은 transcript 동시 resume·append 하는 것 방지.
44
+ // session-id 단위 직렬화 primitive. 기본 경로에서는 호출 uuid 사실상 no-op 이지만,
45
+ // 같은 session-id 를 직접 쓰는 호출자는 여전히 직렬화 보장을 받는다.
65
46
  const sessionChains = new Map<string, Promise<unknown>>();
66
47
 
67
48
  export async function withSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
@@ -93,7 +74,10 @@ export function makeAnthropicWorkerId(tokenId: number): number {
93
74
  // ── envelope decorate ──────────────────────────────────────────────
94
75
 
95
76
  // stream-json 입력 라인에 SDK envelope 부착 후 직렬화. session 소유자인 여기서 한다.
96
- export function decorateAndSerialize(lines: Array<ClaudeStreamJsonLine>, sessionId: string): string {
77
+ export function decorateAndSerialize(
78
+ lines: Array<ClaudeStreamJsonLine>,
79
+ sessionId: string,
80
+ ): string {
97
81
  return (
98
82
  lines
99
83
  .map((line) =>
@@ -118,12 +102,10 @@ export interface ClaudeSessionRequest {
118
102
  jsonSchema?: string; // 있으면 structured output
119
103
  effort?: string;
120
104
  timeoutMs: number;
121
- // 입력: cold(coldHistory+coldInput) 또는 resume(reuseInput).
122
105
  coldHistory?: Array<JsonValue>;
123
106
  input: Array<UserInput>;
124
- // resume 경로면 기존 session-id, cold 면 undefined(새로 발급).
125
- resumeSessionId?: string;
126
107
  abortSignal?: AbortSignal;
108
+ includePartialMessages?: boolean;
127
109
  }
128
110
 
129
111
  export interface ClaudeSessionResult extends ClaudeStreamResult {
@@ -152,13 +134,36 @@ function ensureCwd(): void {
152
134
  }
153
135
 
154
136
  // spawn args 구성. 멀티턴/격리/structured 모드 반영.
137
+ export function applyOneMillionSuffix(model: string, needsSuffix: boolean): string {
138
+ const base = canonicalAnthropicModel(model);
139
+ return needsSuffix ? `${base}[1m]` : base;
140
+ }
141
+
142
+ export function oneMillionEnv(supported: boolean): { CLAUDE_CODE_DISABLE_1M_CONTEXT?: "1" } {
143
+ return supported ? {} : { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" };
144
+ }
145
+
146
+ // SON-495: structured output retry 를 1 로 고정한다. CC 가 attempt 를 reject 하고 retry 하면
147
+ // `{rejected}{accepted}` 누적이 AI-SDK 의 JSON.parse 를 깨뜨리므로, retry 자체를 막아 그 경로를
148
+ // 원천 차단한다. `0` 은 CC query loop(`callsThisQuery >= maxRetries`)가 첫 attempt emit 전에
149
+ // 실패시키므로(`error_max_structured_output_retries`) 외부 값이 1 미만이어도 1 로 클램프한다.
150
+ export function structuredOutputRetriesEnv(raw = process.env.MAX_STRUCTURED_OUTPUT_RETRIES): {
151
+ MAX_STRUCTURED_OUTPUT_RETRIES: string;
152
+ } {
153
+ if (!raw) return { MAX_STRUCTURED_OUTPUT_RETRIES: "1" };
154
+ const parsed = Number.parseInt(raw, 10);
155
+ if (!Number.isFinite(parsed)) return { MAX_STRUCTURED_OUTPUT_RETRIES: "1" };
156
+ return { MAX_STRUCTURED_OUTPUT_RETRIES: String(Math.max(parsed, 1)) };
157
+ }
158
+
155
159
  export function buildClaudeArgs(opts: {
156
160
  model: string;
157
161
  system?: string;
158
162
  effort?: string;
159
163
  jsonSchema?: string;
160
164
  sessionId: string;
161
- isResume: boolean;
165
+ needsOneMillionSuffix?: boolean;
166
+ includePartialMessages?: boolean;
162
167
  }): Array<string> {
163
168
  const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
164
169
  // --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
@@ -176,14 +181,13 @@ export function buildClaudeArgs(opts: {
176
181
  "--output-format",
177
182
  "stream-json",
178
183
  "--verbose",
179
- "--max-turns",
180
- useStructured ? "2" : "1",
184
+ ...(opts.includePartialMessages ? ["--include-partial-messages"] : []),
181
185
  "--permission-mode",
182
186
  "bypassPermissions",
183
187
  "--setting-sources",
184
188
  "project",
185
189
  "--model",
186
- opts.model,
190
+ applyOneMillionSuffix(opts.model, opts.needsOneMillionSuffix ?? false),
187
191
  // inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
188
192
  "--system-prompt",
189
193
  opts.system ?? "",
@@ -192,8 +196,8 @@ export function buildClaudeArgs(opts: {
192
196
  "--effort",
193
197
  opts.effort ?? ANTHROPIC_DEFAULT_EFFORT,
194
198
  "--disable-slash-commands",
195
- // 멀티턴: 첫 호출은 session-id 발급, 후속은 resume. --no-session-persistence 는 쓰지 않는다.
196
- ...(opts.isResume ? ["--resume", opts.sessionId] : ["--session-id", opts.sessionId]),
199
+ "--session-id",
200
+ opts.sessionId,
197
201
  ];
198
202
  if (useStructured) args.push("--json-schema", opts.jsonSchema!);
199
203
  return args;
@@ -209,9 +213,11 @@ export function runClaudeSession(
209
213
  ): Promise<ClaudeSessionResult> {
210
214
  // 정규화 규칙은 canonicalAnthropicModel 이 단독 소유 — dispatcher 가 이미 canonical 을 넘기지만,
211
215
  // 직접 호출(테스트/미래 caller) 대비 방어적으로 한 번 더 통과시킨다(이미 canonical 이면 no-op).
216
+ assertSupportedOneMillionSuffix(req.model);
212
217
  const model = canonicalAnthropicModel(req.model);
213
- const isResume = Boolean(req.resumeSessionId);
214
- const sessionId = req.resumeSessionId ?? randomUUID();
218
+ const supportsOneMillion = supports1MContext(model);
219
+ const needsOneMillionSuffix = needsCli1mSuffix(model);
220
+ const sessionId = randomUUID();
215
221
  const useStructured = Boolean(req.jsonSchema && req.jsonSchema.length > 0);
216
222
  const workerId = makeAnthropicWorkerId(req.tokenId);
217
223
 
@@ -225,17 +231,22 @@ export function runClaudeSession(
225
231
  effort: req.effort,
226
232
  jsonSchema: req.jsonSchema,
227
233
  sessionId,
228
- isResume,
234
+ needsOneMillionSuffix,
235
+ includePartialMessages: req.includePartialMessages,
229
236
  });
230
237
 
231
238
  const inputLines = buildStreamJsonInput({
232
239
  coldHistory: req.coldHistory,
233
240
  input: req.input,
234
- isResume,
235
241
  });
236
242
  const stdinPayload = decorateAndSerialize(inputLines, sessionId);
237
243
 
238
244
  return new Promise<ClaudeSessionResult>((resolve, reject) => {
245
+ if (req.abortSignal?.aborted) {
246
+ reject(new Error("aborted"));
247
+ return;
248
+ }
249
+
239
250
  const child = spawn("claude", args, {
240
251
  // stderr 도 캡처: invalid OAuth / 잘못된 플래그 / CLI 검증 실패가
241
252
  // "closed without result" 로 뭉개지지 않게 close/error 에 stderr 를 실어 보낸다.
@@ -248,9 +259,15 @@ export function runClaudeSession(
248
259
  CLAUDE_CONFIG_DIR: configDir,
249
260
  CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
250
261
  CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
251
- CLAUDE_CODE_DISABLE_1M_CONTEXT: "1",
262
+ ...oneMillionEnv(supportsOneMillion),
252
263
  CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
264
+ CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
265
+ CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
266
+ CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
253
267
  CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
268
+ MAX_THINKING_TOKENS: "0",
269
+ // structured output 일 때만 retry 를 고정한다(SON-495). text 모드는 영향 없음.
270
+ ...(useStructured ? structuredOutputRetriesEnv() : {}),
254
271
  },
255
272
  });
256
273
 
@@ -264,9 +281,11 @@ export function runClaudeSession(
264
281
  stderrBuf = (stderrBuf + d.toString()).slice(-STDERR_CAP);
265
282
  });
266
283
  const stderrSuffix = () => (stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim()}` : "");
284
+ let cleanupAbort: (() => void) | undefined;
267
285
  const timer = setTimeout(() => {
268
286
  if (settled) return;
269
287
  settled = true;
288
+ cleanupAbort?.();
270
289
  child.kill();
271
290
  reject(new Error(`Claude session timeout after ${req.timeoutMs / 1000}s`));
272
291
  }, req.timeoutMs);
@@ -275,16 +294,20 @@ export function runClaudeSession(
275
294
  if (settled) return;
276
295
  settled = true;
277
296
  clearTimeout(timer);
297
+ cleanupAbort?.();
278
298
  resolve({ ...result, sessionId, workerId });
279
299
  };
280
300
 
281
- req.abortSignal?.addEventListener("abort", () => {
301
+ const onAbort = () => {
282
302
  if (settled) return;
283
303
  settled = true;
284
304
  clearTimeout(timer);
305
+ cleanupAbort?.();
285
306
  child.kill();
286
307
  reject(new Error("aborted"));
287
- });
308
+ };
309
+ cleanupAbort = () => req.abortSignal?.removeEventListener("abort", onAbort);
310
+ req.abortSignal?.addEventListener("abort", onAbort, { once: true });
288
311
 
289
312
  child.stdout?.on("data", (d: Buffer) => {
290
313
  if (settled) return;
@@ -307,12 +330,14 @@ export function runClaudeSession(
307
330
  if (settled) return;
308
331
  settled = true;
309
332
  clearTimeout(timer);
333
+ cleanupAbort?.();
310
334
  reject(new Error(`Claude session closed without result${stderrSuffix()}`));
311
335
  });
312
336
  child.on("error", (err) => {
313
337
  if (settled) return;
314
338
  settled = true;
315
339
  clearTimeout(timer);
340
+ cleanupAbort?.();
316
341
  reject(new Error(`Claude session spawn error: ${err.message}`));
317
342
  });
318
343
 
@@ -21,7 +21,7 @@ function userLineCount(lines: Array<ClaudeStreamJsonLine>): number {
21
21
 
22
22
  describe("buildStreamJsonInput", () => {
23
23
  it("happy: 단일 user input → user 1줄", () => {
24
- const lines = buildStreamJsonInput({ input: userText("안녕"), isResume: false });
24
+ const lines = buildStreamJsonInput({ input: userText("안녕") });
25
25
  expect(lines).toHaveLength(1);
26
26
  expect(userLineCount(lines)).toBe(1);
27
27
  expect(lines[0]).toEqual({
@@ -42,7 +42,6 @@ describe("buildStreamJsonInput", () => {
42
42
  const lines = buildStreamJsonInput({
43
43
  coldHistory,
44
44
  input: userText("비밀번호 뭐였지?"),
45
- isResume: false,
46
45
  });
47
46
  // assistant context 1줄 + 실행 user 1줄
48
47
  expect(lines).toHaveLength(2);
@@ -57,28 +56,8 @@ describe("buildStreamJsonInput", () => {
57
56
  });
58
57
  });
59
58
 
60
- it("resume: reuseInput(delta) user 1줄만, history 미포함", () => {
61
- const lines = buildStreamJsonInput({
62
- coldHistory: [
63
- {
64
- type: "message",
65
- role: "assistant",
66
- content: [{ type: "output_text", text: "이전 답변" }],
67
- },
68
- ],
69
- input: userText("다음 질문"),
70
- isResume: true,
71
- });
72
- expect(lines).toHaveLength(1);
73
- expect(userLineCount(lines)).toBe(1);
74
- expect(lines[0]).toEqual({
75
- type: "user",
76
- message: { role: "user", content: [{ type: "text", text: "다음 질문" }] },
77
- });
78
- });
79
-
80
59
  it("정규화: content 는 항상 block 배열 (KTD5 구현계약)", () => {
81
- const lines = buildStreamJsonInput({ input: userText("x"), isResume: false });
60
+ const lines = buildStreamJsonInput({ input: userText("x") });
82
61
  expect(Array.isArray(lines[0]!.message.content)).toBe(true);
83
62
  expect(lines[0]!.message.content[0]).toHaveProperty("type", "text");
84
63
  });
@@ -91,7 +70,6 @@ describe("buildStreamJsonInput", () => {
91
70
  const lines = buildStreamJsonInput({
92
71
  coldHistory,
93
72
  input: userText("결과는?"),
94
- isResume: false,
95
73
  });
96
74
  // assistant context 1줄 + 실행 user 1줄
97
75
  expect(lines).toHaveLength(2);
@@ -110,7 +88,6 @@ describe("buildStreamJsonInput", () => {
110
88
  const lines = buildStreamJsonInput({
111
89
  coldHistory: [],
112
90
  input: userText("단발"),
113
- isResume: false,
114
91
  });
115
92
  expect(lines).toHaveLength(1);
116
93
  expect(lines[0]!.type).toBe("user");
@@ -122,7 +99,7 @@ describe("buildStreamJsonInput", () => {
122
99
  { type: "message", role: "assistant", content: [{ type: "output_text", text: "a1" }] },
123
100
  { type: "message", role: "assistant", content: [{ type: "output_text", text: "a2" }] },
124
101
  ];
125
- const lines = buildStreamJsonInput({ coldHistory, input: userText("q"), isResume: false });
102
+ const lines = buildStreamJsonInput({ coldHistory, input: userText("q") });
126
103
  expect(lines).toHaveLength(2);
127
104
  expect(userLineCount(lines)).toBe(1);
128
105
  expect(lines[0]!.type).toBe("assistant");
@@ -137,7 +114,7 @@ describe("buildStreamJsonInput", () => {
137
114
  { type: "message", role: "user", content: [{ type: "input_text", text: "u2" }] },
138
115
  { type: "function_call_output", call_id: "x", output: "out" },
139
116
  ];
140
- const lines = buildStreamJsonInput({ coldHistory, input: userText("final"), isResume: false });
117
+ const lines = buildStreamJsonInput({ coldHistory, input: userText("final") });
141
118
  expect(userLineCount(lines)).toBe(1);
142
119
  expect(lines[lines.length - 1]!.message.content[0]!.text).toBe("final");
143
120
  });
@@ -145,7 +122,7 @@ describe("buildStreamJsonInput", () => {
145
122
 
146
123
  describe("serializeStreamJsonInput", () => {
147
124
  it("JSONL 한 줄당 하나 + 마지막 개행", () => {
148
- const lines = buildStreamJsonInput({ input: userText("hi"), isResume: false });
125
+ const lines = buildStreamJsonInput({ input: userText("hi") });
149
126
  const out = serializeStreamJsonInput(lines);
150
127
  expect(out.endsWith("\n")).toBe(true);
151
128
  const parsed = out
@@ -230,7 +207,7 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
230
207
  expect(deltas).not.toContain(" (thinking)");
231
208
  });
232
209
 
233
- it("structured tool-call: 첫 StructuredOutput 보존하고 max_turns result 성공 처리", () => {
210
+ it("structured tool-call: 첫 StructuredOutput 보존하되 max_turns result error 유지", () => {
234
211
  const first = {
235
212
  action: "tool_call",
236
213
  answer: null,
@@ -249,6 +226,18 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
249
226
  content: [{ type: "tool_use", name: "StructuredOutput", input: first }],
250
227
  },
251
228
  }),
229
+ JSON.stringify({
230
+ type: "user",
231
+ message: {
232
+ content: [
233
+ {
234
+ type: "tool_result",
235
+ is_error: true,
236
+ content: "Output does not match required schema",
237
+ },
238
+ ],
239
+ },
240
+ }),
252
241
  JSON.stringify({
253
242
  type: "assistant",
254
243
  message: {
@@ -269,13 +258,68 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
269
258
  );
270
259
 
271
260
  expect(JSON.parse(result!.text)).toEqual(first);
272
- expect(result!.isError).toBe(false);
261
+ expect(result!.isError).toBe(true);
273
262
  expect(result!.usage.inputTokens).toBe(30);
274
263
  expect(result!.usage.outputTokens).toBe(30);
275
264
  expect(result!.durationMs).toBe(1234);
276
265
  expect(result!.costUsd).toBe(0.0042);
277
266
  });
278
267
 
268
+ // SON-495: error_max_structured_output_retries 는 모델이 schema 를 못 맞춘 비정상 종료다.
269
+ // CC structured output 은 constrained decoding 이 아니라 사후 AJV 검증이라, 모델이 가끔
270
+ // placeholder($PARAMETER_NAME)·거부("just kidding")·필드 누락 같은 발작/불완전 출력을 낸다.
271
+ // 이를 "거의 맞았으니 살리자"고 흘리면 클라이언트 검증이 깨지거나 쓰레기가 샌다(실측 11674/11675).
272
+ // 그래서 보존 출력 유무·내용과 무관하게 항상 에러로 처리한다(알려진 best practice: refusal/degenerate
273
+ // output 은 graceful error). 보존 출력은 진단용 text 로는 남기되 isError 는 true.
274
+ it("error_max_structured_output_retries: 보존 출력이 valid JSON 이어도 에러로 처리", () => {
275
+ const attempt = {
276
+ scenes: [{ visual: { backgroundId: "IL-LOC-02" }, contents: [], advance: "choice" }],
277
+ };
278
+ const { result } = runLines(
279
+ [
280
+ JSON.stringify({
281
+ type: "assistant",
282
+ message: { content: [{ type: "tool_use", name: "StructuredOutput", input: attempt }] },
283
+ }),
284
+ JSON.stringify({
285
+ type: "result",
286
+ subtype: "error_max_structured_output_retries",
287
+ is_error: true,
288
+ usage: { input_tokens: 10, output_tokens: 20 },
289
+ duration_ms: 100,
290
+ total_cost_usd: 0.001,
291
+ }),
292
+ ],
293
+ { structuredOutput: true },
294
+ );
295
+
296
+ expect(result!.isError).toBe(true);
297
+ expect(result!.subtype).toBe("error_max_structured_output_retries");
298
+ });
299
+
300
+ it("error_max_structured_output_retries: placeholder 쓰레기 출력도 에러로 처리", () => {
301
+ const garbage = { $PARAMETER_NAME: "" };
302
+ const { result } = runLines(
303
+ [
304
+ JSON.stringify({
305
+ type: "assistant",
306
+ message: { content: [{ type: "tool_use", name: "StructuredOutput", input: garbage }] },
307
+ }),
308
+ JSON.stringify({
309
+ type: "result",
310
+ subtype: "error_max_structured_output_retries",
311
+ is_error: true,
312
+ usage: { input_tokens: 10, output_tokens: 0 },
313
+ duration_ms: 100,
314
+ total_cost_usd: 0.001,
315
+ }),
316
+ ],
317
+ { structuredOutput: true },
318
+ );
319
+
320
+ expect(result!.isError).toBe(true);
321
+ });
322
+
279
323
  it("text 모드: partial_json 이 와도 무시, text_delta 만", () => {
280
324
  const { deltas } = runLines(
281
325
  [