@cartanova/qgrid-cli 2.2.1 → 2.3.2
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/conv-routing.js +2 -6
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +74 -43
- package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
- package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
- package/bundle/dist/application/request-log/request-log.model.js +54 -8
- package/bundle/dist/sonamu.config.js +11 -2
- package/bundle/dist/testing/global.js +5 -2
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
- package/bundle/dist/utils/providers/common/model-cost.js +35 -18
- package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
- package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
- package/bundle/src/application/qgrid/conv-routing.ts +3 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +110 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +112 -64
- package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
- package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
- package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
- package/bundle/src/application/request-log/request-log.model.ts +98 -13
- package/bundle/src/sonamu.config.ts +13 -2
- package/bundle/src/testing/global.ts +16 -2
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
- package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
- package/bundle/src/utils/providers/common/model-cost.ts +107 -19
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
- package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
- package/package.json +1 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
4
|
+
import { type UserInput } from "../../../codex-protocol/v2/UserInput";
|
|
5
|
+
import {
|
|
6
|
+
buildStreamJsonInput,
|
|
7
|
+
type ClaudeStreamJsonState,
|
|
8
|
+
type ClaudeStreamJsonLine,
|
|
9
|
+
handleStreamJsonLine,
|
|
10
|
+
serializeStreamJsonInput,
|
|
11
|
+
} from "./stream-json-adapter";
|
|
12
|
+
|
|
13
|
+
function userText(text: string): Array<UserInput> {
|
|
14
|
+
return [{ type: "text", text, text_elements: [] }];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 실행 가능한 user 줄 개수 — 항상 정확히 1이어야 함 (codex P0-1/P0-2 핵심 불변식).
|
|
18
|
+
function userLineCount(lines: Array<ClaudeStreamJsonLine>): number {
|
|
19
|
+
return lines.filter((l) => l.type === "user").length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("buildStreamJsonInput", () => {
|
|
23
|
+
it("happy: 단일 user input → user 1줄", () => {
|
|
24
|
+
const lines = buildStreamJsonInput({ input: userText("안녕"), isResume: false });
|
|
25
|
+
expect(lines).toHaveLength(1);
|
|
26
|
+
expect(userLineCount(lines)).toBe(1);
|
|
27
|
+
expect(lines[0]).toEqual({
|
|
28
|
+
type: "user",
|
|
29
|
+
message: { role: "user", content: [{ type: "text", text: "안녕" }] },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("멀티턴 cold: history 는 단일 assistant context 로 평탄화, 실행 user 는 1줄만 (P0-1/P0-2)", () => {
|
|
34
|
+
const coldHistory: Array<JsonValue> = [
|
|
35
|
+
{
|
|
36
|
+
type: "message",
|
|
37
|
+
role: "user",
|
|
38
|
+
content: [{ type: "input_text", text: "비밀번호는 PURPLE" }],
|
|
39
|
+
},
|
|
40
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "기억할게" }] },
|
|
41
|
+
];
|
|
42
|
+
const lines = buildStreamJsonInput({
|
|
43
|
+
coldHistory,
|
|
44
|
+
input: userText("비밀번호 뭐였지?"),
|
|
45
|
+
isResume: false,
|
|
46
|
+
});
|
|
47
|
+
// assistant context 1줄 + 실행 user 1줄
|
|
48
|
+
expect(lines).toHaveLength(2);
|
|
49
|
+
// 실행 가능한 user 줄은 정확히 1개 (과거 user 가 재실행되지 않음)
|
|
50
|
+
expect(userLineCount(lines)).toBe(1);
|
|
51
|
+
expect(lines[0]!.type).toBe("assistant");
|
|
52
|
+
expect(lines[0]!.message.content[0]!.text).toContain("User: 비밀번호는 PURPLE");
|
|
53
|
+
expect(lines[0]!.message.content[0]!.text).toContain("Assistant: 기억할게");
|
|
54
|
+
expect(lines[1]!).toEqual({
|
|
55
|
+
type: "user",
|
|
56
|
+
message: { role: "user", content: [{ type: "text", text: "비밀번호 뭐였지?" }] },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
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
|
+
it("정규화: content 는 항상 block 배열 (KTD5 구현계약)", () => {
|
|
81
|
+
const lines = buildStreamJsonInput({ input: userText("x"), isResume: false });
|
|
82
|
+
expect(Array.isArray(lines[0]!.message.content)).toBe(true);
|
|
83
|
+
expect(lines[0]!.message.content[0]).toHaveProperty("type", "text");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("tool history(KTD10): function_call/output 도 assistant context 로 평탄화, 실행 user 1줄만", () => {
|
|
87
|
+
const coldHistory: Array<JsonValue> = [
|
|
88
|
+
{ type: "function_call", name: "rollDice", arguments: '{"sides":20}', call_id: "tu_1" },
|
|
89
|
+
{ type: "function_call_output", call_id: "tu_1", output: "17" },
|
|
90
|
+
];
|
|
91
|
+
const lines = buildStreamJsonInput({
|
|
92
|
+
coldHistory,
|
|
93
|
+
input: userText("결과는?"),
|
|
94
|
+
isResume: false,
|
|
95
|
+
});
|
|
96
|
+
// assistant context 1줄 + 실행 user 1줄
|
|
97
|
+
expect(lines).toHaveLength(2);
|
|
98
|
+
expect(userLineCount(lines)).toBe(1); // tool output 이 user 로 재실행되지 않음 (P0-2)
|
|
99
|
+
expect(lines[0]!.type).toBe("assistant");
|
|
100
|
+
const ctx = lines[0]!.message.content[0]!.text;
|
|
101
|
+
expect(ctx).toContain("Tool call: rollDice");
|
|
102
|
+
expect(ctx).toContain("Tool result: 17");
|
|
103
|
+
// native tool_use / tool_result 블록이 없어야 함 (text 만)
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
expect(line.message.content.every((c) => c.type === "text")).toBe(true);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("edge: 빈 coldHistory → user 1줄만 (assistant context 안 만듦)", () => {
|
|
110
|
+
const lines = buildStreamJsonInput({
|
|
111
|
+
coldHistory: [],
|
|
112
|
+
input: userText("단발"),
|
|
113
|
+
isResume: false,
|
|
114
|
+
});
|
|
115
|
+
expect(lines).toHaveLength(1);
|
|
116
|
+
expect(lines[0]!.type).toBe("user");
|
|
117
|
+
expect(lines[0]!.message.content[0]!.text).toBe("단발");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("edge: assistant 만 연속인 history → assistant context 1줄 + user 1줄", () => {
|
|
121
|
+
const coldHistory: Array<JsonValue> = [
|
|
122
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "a1" }] },
|
|
123
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "a2" }] },
|
|
124
|
+
];
|
|
125
|
+
const lines = buildStreamJsonInput({ coldHistory, input: userText("q"), isResume: false });
|
|
126
|
+
expect(lines).toHaveLength(2);
|
|
127
|
+
expect(userLineCount(lines)).toBe(1);
|
|
128
|
+
expect(lines[0]!.type).toBe("assistant");
|
|
129
|
+
expect(lines[0]!.message.content[0]!.text).toContain("a1");
|
|
130
|
+
expect(lines[0]!.message.content[0]!.text).toContain("a2");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("불변식: cold 경로에서도 실행 user 줄은 절대 1개 초과 금지", () => {
|
|
134
|
+
const coldHistory: Array<JsonValue> = [
|
|
135
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "u1" }] },
|
|
136
|
+
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "a1" }] },
|
|
137
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "u2" }] },
|
|
138
|
+
{ type: "function_call_output", call_id: "x", output: "out" },
|
|
139
|
+
];
|
|
140
|
+
const lines = buildStreamJsonInput({ coldHistory, input: userText("final"), isResume: false });
|
|
141
|
+
expect(userLineCount(lines)).toBe(1);
|
|
142
|
+
expect(lines[lines.length - 1]!.message.content[0]!.text).toBe("final");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("serializeStreamJsonInput", () => {
|
|
147
|
+
it("JSONL 한 줄당 하나 + 마지막 개행", () => {
|
|
148
|
+
const lines = buildStreamJsonInput({ input: userText("hi"), isResume: false });
|
|
149
|
+
const out = serializeStreamJsonInput(lines);
|
|
150
|
+
expect(out.endsWith("\n")).toBe(true);
|
|
151
|
+
const parsed = out
|
|
152
|
+
.trim()
|
|
153
|
+
.split("\n")
|
|
154
|
+
.map((l) => JSON.parse(l));
|
|
155
|
+
expect(parsed).toHaveLength(1);
|
|
156
|
+
expect(parsed[0]!.message.role).toBe("user");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// 출력 어댑터(U3): 라인을 onDelta 와 함께 흘려 delta 수집 + 최종 result 반환.
|
|
161
|
+
function runLines(
|
|
162
|
+
lines: Array<string>,
|
|
163
|
+
opts?: { structuredOutput?: boolean; state?: ClaudeStreamJsonState },
|
|
164
|
+
): {
|
|
165
|
+
deltas: Array<string>;
|
|
166
|
+
result: ReturnType<typeof handleStreamJsonLine>;
|
|
167
|
+
state: ClaudeStreamJsonState;
|
|
168
|
+
} {
|
|
169
|
+
const deltas: Array<string> = [];
|
|
170
|
+
let result: ReturnType<typeof handleStreamJsonLine> = null;
|
|
171
|
+
const state = opts?.state ?? {};
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const r = handleStreamJsonLine(line, (t) => deltas.push(t), { ...opts, state });
|
|
174
|
+
if (r) result = r;
|
|
175
|
+
}
|
|
176
|
+
return { deltas, result, state };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function streamEvent(deltaType: string, payload: Record<string, unknown>): string {
|
|
180
|
+
return JSON.stringify({
|
|
181
|
+
type: "stream_event",
|
|
182
|
+
event: { type: "content_block_delta", delta: { type: deltaType, ...payload } },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
describe("handleStreamJsonLine (출력 어댑터)", () => {
|
|
187
|
+
it("happy text: text_delta → onDelta×N, result → text 수집", () => {
|
|
188
|
+
const { deltas, result } = runLines([
|
|
189
|
+
streamEvent("text_delta", { text: "안" }),
|
|
190
|
+
streamEvent("text_delta", { text: "녕" }),
|
|
191
|
+
JSON.stringify({ type: "result", result: "안녕", usage: { output_tokens: 2 } }),
|
|
192
|
+
]);
|
|
193
|
+
expect(deltas).toEqual(["안", "녕"]);
|
|
194
|
+
expect(result!.text).toBe("안녕");
|
|
195
|
+
expect(result!.isError).toBe(false);
|
|
196
|
+
expect(result!.quotaExhausted).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("streamObject(structured 모드): partial_json 조각 → onDelta, 누적 시 유효 JSON", () => {
|
|
200
|
+
const { deltas, result } = runLines(
|
|
201
|
+
[
|
|
202
|
+
streamEvent("input_json_delta", { partial_json: '{"name":"Marg' }),
|
|
203
|
+
streamEvent("input_json_delta", { partial_json: 'uerite","age":34}' }),
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
type: "result",
|
|
206
|
+
structured_output: { name: "Marguerite", age: 34 },
|
|
207
|
+
usage: { output_tokens: 10 },
|
|
208
|
+
}),
|
|
209
|
+
],
|
|
210
|
+
{ structuredOutput: true },
|
|
211
|
+
);
|
|
212
|
+
expect(deltas.join("")).toBe('{"name":"Marguerite","age":34}');
|
|
213
|
+
expect(JSON.parse(result!.text)).toEqual({ name: "Marguerite", age: 34 });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("streamObject 혼합(P0): structured 모드에서 자연어 text_delta 는 버리고 partial_json 만 onDelta", () => {
|
|
217
|
+
const { deltas } = runLines(
|
|
218
|
+
[
|
|
219
|
+
// Claude 가 JSON 앞에 자연어를 흘리는 PoC 실패 모드 재현
|
|
220
|
+
streamEvent("text_delta", { text: "Here is the JSON:" }),
|
|
221
|
+
streamEvent("input_json_delta", { partial_json: '{"a":' }),
|
|
222
|
+
streamEvent("text_delta", { text: " (thinking)" }),
|
|
223
|
+
streamEvent("input_json_delta", { partial_json: "1}" }),
|
|
224
|
+
],
|
|
225
|
+
{ structuredOutput: true },
|
|
226
|
+
);
|
|
227
|
+
// 오직 JSON 조각만 — prose 가 섞이면 client streamObject 파서가 깨짐
|
|
228
|
+
expect(deltas.join("")).toBe('{"a":1}');
|
|
229
|
+
expect(deltas).not.toContain("Here is the JSON:");
|
|
230
|
+
expect(deltas).not.toContain(" (thinking)");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("structured tool-call: 첫 StructuredOutput을 보존하고 max_turns result를 성공 처리", () => {
|
|
234
|
+
const first = {
|
|
235
|
+
action: "tool_call",
|
|
236
|
+
answer: null,
|
|
237
|
+
toolCalls: [{ toolName: "getWeather", args: '{"city":"Seoul"}' }],
|
|
238
|
+
};
|
|
239
|
+
const later = {
|
|
240
|
+
action: "answer",
|
|
241
|
+
answer: "tool unavailable",
|
|
242
|
+
toolCalls: null,
|
|
243
|
+
};
|
|
244
|
+
const { result } = runLines(
|
|
245
|
+
[
|
|
246
|
+
JSON.stringify({
|
|
247
|
+
type: "assistant",
|
|
248
|
+
message: {
|
|
249
|
+
content: [{ type: "tool_use", name: "StructuredOutput", input: first }],
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
JSON.stringify({
|
|
253
|
+
type: "assistant",
|
|
254
|
+
message: {
|
|
255
|
+
content: [{ type: "tool_use", name: "StructuredOutput", input: later }],
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
JSON.stringify({
|
|
259
|
+
type: "result",
|
|
260
|
+
subtype: "error_max_turns",
|
|
261
|
+
terminal_reason: "max_turns",
|
|
262
|
+
structured_output: later,
|
|
263
|
+
usage: { input_tokens: 10, cache_creation_input_tokens: 20, output_tokens: 30 },
|
|
264
|
+
duration_ms: 1234,
|
|
265
|
+
total_cost_usd: 0.0042,
|
|
266
|
+
}),
|
|
267
|
+
],
|
|
268
|
+
{ structuredOutput: true },
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(JSON.parse(result!.text)).toEqual(first);
|
|
272
|
+
expect(result!.isError).toBe(false);
|
|
273
|
+
expect(result!.usage.inputTokens).toBe(30);
|
|
274
|
+
expect(result!.usage.outputTokens).toBe(30);
|
|
275
|
+
expect(result!.durationMs).toBe(1234);
|
|
276
|
+
expect(result!.costUsd).toBe(0.0042);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("text 모드: partial_json 이 와도 무시, text_delta 만", () => {
|
|
280
|
+
const { deltas } = runLines(
|
|
281
|
+
[
|
|
282
|
+
streamEvent("text_delta", { text: "hello" }),
|
|
283
|
+
streamEvent("input_json_delta", { partial_json: '{"x":1}' }),
|
|
284
|
+
],
|
|
285
|
+
{ structuredOutput: false },
|
|
286
|
+
);
|
|
287
|
+
expect(deltas).toEqual(["hello"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("usage: 4 카테고리 → TokenUsageBreakdown (inputTokens 는 cache 포함 전체 입력)", () => {
|
|
291
|
+
const { result } = runLines([
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
type: "result",
|
|
294
|
+
result: "ok",
|
|
295
|
+
usage: {
|
|
296
|
+
input_tokens: 10,
|
|
297
|
+
cache_creation_input_tokens: 2274,
|
|
298
|
+
cache_read_input_tokens: 14853,
|
|
299
|
+
output_tokens: 111,
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
302
|
+
]);
|
|
303
|
+
expect(result!.usage).toEqual({
|
|
304
|
+
totalTokens: 10 + 2274 + 14853 + 111,
|
|
305
|
+
inputTokens: 10 + 2274 + 14853,
|
|
306
|
+
cachedInputTokens: 14853,
|
|
307
|
+
cacheCreationInputTokens: 2274,
|
|
308
|
+
outputTokens: 111,
|
|
309
|
+
reasoningOutputTokens: 0,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("structured_output 우선, 없으면 result(코드펜스 제거)", () => {
|
|
314
|
+
const fenced = runLines([
|
|
315
|
+
JSON.stringify({ type: "result", result: '```json\n{"a":1}\n```', usage: {} }),
|
|
316
|
+
]);
|
|
317
|
+
expect(fenced.result!.text).toBe('{"a":1}');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("stripCodeFence(P2): 앞뒤 공백/개행 있는 펜스도 깨끗이 제거", () => {
|
|
321
|
+
const r = runLines([
|
|
322
|
+
JSON.stringify({ type: "result", result: '\n```json\n{"b":2}\n```\n', usage: {} }),
|
|
323
|
+
]);
|
|
324
|
+
expect(r.result!.text).toBe('{"b":2}');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("error(P1): is_error / terminal model_error / error_* subtype → isError true", () => {
|
|
328
|
+
const cases = [
|
|
329
|
+
{ is_error: true, result: "boom" },
|
|
330
|
+
{ terminal_reason: "model_error", result: "x" },
|
|
331
|
+
{ subtype: "error_during_execution", result: "x" },
|
|
332
|
+
{ subtype: "error_max_turns", result: "x" },
|
|
333
|
+
{ subtype: "error_max_budget_usd", result: "x" },
|
|
334
|
+
{ subtype: "error_max_structured_output_retries", result: "x" },
|
|
335
|
+
];
|
|
336
|
+
for (const c of cases) {
|
|
337
|
+
const { result } = runLines([JSON.stringify({ type: "result", usage: {}, ...c })]);
|
|
338
|
+
expect(result!.isError, JSON.stringify(c)).toBe(true);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("success(P1): subtype==='success' 또는 subtype 없음 → isError false", () => {
|
|
343
|
+
const ok1 = runLines([
|
|
344
|
+
JSON.stringify({ type: "result", subtype: "success", result: "ok", usage: {} }),
|
|
345
|
+
]);
|
|
346
|
+
expect(ok1.result!.isError).toBe(false);
|
|
347
|
+
const ok2 = runLines([JSON.stringify({ type: "result", result: "ok", usage: {} })]);
|
|
348
|
+
expect(ok2.result!.isError).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("quota: result text가 'You've hit'로 시작 → quotaExhausted true", () => {
|
|
352
|
+
const { result } = runLines([
|
|
353
|
+
JSON.stringify({ type: "result", result: "You've hit your limit", usage: {} }),
|
|
354
|
+
]);
|
|
355
|
+
expect(result!.quotaExhausted).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("깨진 JSON 라인 / 빈 줄 → graceful skip (null)", () => {
|
|
359
|
+
expect(handleStreamJsonLine("not json {{{", () => {})).toBeNull();
|
|
360
|
+
expect(handleStreamJsonLine("", () => {})).toBeNull();
|
|
361
|
+
expect(handleStreamJsonLine(" ", () => {})).toBeNull();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("system/init 등 비-result/비-delta 이벤트 → null, delta 없음", () => {
|
|
365
|
+
const deltas: Array<string> = [];
|
|
366
|
+
const r = handleStreamJsonLine(
|
|
367
|
+
JSON.stringify({ type: "system", subtype: "init", session_id: "x" }),
|
|
368
|
+
(t) => deltas.push(t),
|
|
369
|
+
);
|
|
370
|
+
expect(r).toBeNull();
|
|
371
|
+
expect(deltas).toHaveLength(0);
|
|
372
|
+
});
|
|
373
|
+
});
|