@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.
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +94 -278
- package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +42 -98
- package/bundle/dist/utils/providers/anthropic/claude-session.js +54 -32
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +7 -37
- package/bundle/dist/utils/providers/common/model-cost.js +3 -2
- package/bundle/dist/utils/providers/openai/codex-worker.js +1 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +19 -12
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +235 -7
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +152 -364
- package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +61 -188
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +89 -62
- package/bundle/src/utils/providers/anthropic/claude-session.ts +71 -46
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +74 -30
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +40 -102
- package/bundle/src/utils/providers/common/model-cost.test.ts +19 -1
- package/bundle/src/utils/providers/common/model-cost.ts +2 -1
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +7 -4
- package/bundle/src/utils/providers/openai/codex-worker.ts +2 -8
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +18 -11
- package/package.json +1 -1
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* claude-session — Claude CLI 세션 spawn + QgridThreadCoord 매핑 + per-session 락.
|
|
3
3
|
*
|
|
4
|
-
* 멀티턴:
|
|
5
|
-
*
|
|
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
|
-
* -
|
|
12
|
-
*
|
|
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 {
|
|
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 단위
|
|
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(
|
|
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
|
-
|
|
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
|
-
"--
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
214
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("안녕")
|
|
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")
|
|
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")
|
|
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")
|
|
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")
|
|
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
|
|
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(
|
|
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
|
[
|