@cartanova/qgrid-cli 2.3.2 → 2.3.3

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 +91 -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 +39 -96
  5. package/bundle/dist/utils/providers/anthropic/claude-session.js +47 -32
  6. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +3 -36
  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 +193 -6
  11. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +140 -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 +50 -185
  17. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +71 -62
  18. package/bundle/src/utils/providers/anthropic/claude-session.ts +56 -46
  19. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +19 -30
  20. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +24 -101
  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,23 @@ 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
+
155
146
  export function buildClaudeArgs(opts: {
156
147
  model: string;
157
148
  system?: string;
158
149
  effort?: string;
159
150
  jsonSchema?: string;
160
151
  sessionId: string;
161
- isResume: boolean;
152
+ needsOneMillionSuffix?: boolean;
153
+ includePartialMessages?: boolean;
162
154
  }): Array<string> {
163
155
  const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
164
156
  // --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
@@ -176,14 +168,13 @@ export function buildClaudeArgs(opts: {
176
168
  "--output-format",
177
169
  "stream-json",
178
170
  "--verbose",
179
- "--max-turns",
180
- useStructured ? "2" : "1",
171
+ ...(opts.includePartialMessages ? ["--include-partial-messages"] : []),
181
172
  "--permission-mode",
182
173
  "bypassPermissions",
183
174
  "--setting-sources",
184
175
  "project",
185
176
  "--model",
186
- opts.model,
177
+ applyOneMillionSuffix(opts.model, opts.needsOneMillionSuffix ?? false),
187
178
  // inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
188
179
  "--system-prompt",
189
180
  opts.system ?? "",
@@ -192,8 +183,8 @@ export function buildClaudeArgs(opts: {
192
183
  "--effort",
193
184
  opts.effort ?? ANTHROPIC_DEFAULT_EFFORT,
194
185
  "--disable-slash-commands",
195
- // 멀티턴: 첫 호출은 session-id 발급, 후속은 resume. --no-session-persistence 는 쓰지 않는다.
196
- ...(opts.isResume ? ["--resume", opts.sessionId] : ["--session-id", opts.sessionId]),
186
+ "--session-id",
187
+ opts.sessionId,
197
188
  ];
198
189
  if (useStructured) args.push("--json-schema", opts.jsonSchema!);
199
190
  return args;
@@ -209,9 +200,11 @@ export function runClaudeSession(
209
200
  ): Promise<ClaudeSessionResult> {
210
201
  // 정규화 규칙은 canonicalAnthropicModel 이 단독 소유 — dispatcher 가 이미 canonical 을 넘기지만,
211
202
  // 직접 호출(테스트/미래 caller) 대비 방어적으로 한 번 더 통과시킨다(이미 canonical 이면 no-op).
203
+ assertSupportedOneMillionSuffix(req.model);
212
204
  const model = canonicalAnthropicModel(req.model);
213
- const isResume = Boolean(req.resumeSessionId);
214
- const sessionId = req.resumeSessionId ?? randomUUID();
205
+ const supportsOneMillion = supports1MContext(model);
206
+ const needsOneMillionSuffix = needsCli1mSuffix(model);
207
+ const sessionId = randomUUID();
215
208
  const useStructured = Boolean(req.jsonSchema && req.jsonSchema.length > 0);
216
209
  const workerId = makeAnthropicWorkerId(req.tokenId);
217
210
 
@@ -225,17 +218,22 @@ export function runClaudeSession(
225
218
  effort: req.effort,
226
219
  jsonSchema: req.jsonSchema,
227
220
  sessionId,
228
- isResume,
221
+ needsOneMillionSuffix,
222
+ includePartialMessages: req.includePartialMessages,
229
223
  });
230
224
 
231
225
  const inputLines = buildStreamJsonInput({
232
226
  coldHistory: req.coldHistory,
233
227
  input: req.input,
234
- isResume,
235
228
  });
236
229
  const stdinPayload = decorateAndSerialize(inputLines, sessionId);
237
230
 
238
231
  return new Promise<ClaudeSessionResult>((resolve, reject) => {
232
+ if (req.abortSignal?.aborted) {
233
+ reject(new Error("aborted"));
234
+ return;
235
+ }
236
+
239
237
  const child = spawn("claude", args, {
240
238
  // stderr 도 캡처: invalid OAuth / 잘못된 플래그 / CLI 검증 실패가
241
239
  // "closed without result" 로 뭉개지지 않게 close/error 에 stderr 를 실어 보낸다.
@@ -248,9 +246,13 @@ export function runClaudeSession(
248
246
  CLAUDE_CONFIG_DIR: configDir,
249
247
  CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
250
248
  CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
251
- CLAUDE_CODE_DISABLE_1M_CONTEXT: "1",
249
+ ...oneMillionEnv(supportsOneMillion),
252
250
  CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
251
+ CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
252
+ CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
253
+ CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
253
254
  CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
255
+ MAX_THINKING_TOKENS: "0",
254
256
  },
255
257
  });
256
258
 
@@ -264,9 +266,11 @@ export function runClaudeSession(
264
266
  stderrBuf = (stderrBuf + d.toString()).slice(-STDERR_CAP);
265
267
  });
266
268
  const stderrSuffix = () => (stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim()}` : "");
269
+ let cleanupAbort: (() => void) | undefined;
267
270
  const timer = setTimeout(() => {
268
271
  if (settled) return;
269
272
  settled = true;
273
+ cleanupAbort?.();
270
274
  child.kill();
271
275
  reject(new Error(`Claude session timeout after ${req.timeoutMs / 1000}s`));
272
276
  }, req.timeoutMs);
@@ -275,16 +279,20 @@ export function runClaudeSession(
275
279
  if (settled) return;
276
280
  settled = true;
277
281
  clearTimeout(timer);
282
+ cleanupAbort?.();
278
283
  resolve({ ...result, sessionId, workerId });
279
284
  };
280
285
 
281
- req.abortSignal?.addEventListener("abort", () => {
286
+ const onAbort = () => {
282
287
  if (settled) return;
283
288
  settled = true;
284
289
  clearTimeout(timer);
290
+ cleanupAbort?.();
285
291
  child.kill();
286
292
  reject(new Error("aborted"));
287
- });
293
+ };
294
+ cleanupAbort = () => req.abortSignal?.removeEventListener("abort", onAbort);
295
+ req.abortSignal?.addEventListener("abort", onAbort, { once: true });
288
296
 
289
297
  child.stdout?.on("data", (d: Buffer) => {
290
298
  if (settled) return;
@@ -307,12 +315,14 @@ export function runClaudeSession(
307
315
  if (settled) return;
308
316
  settled = true;
309
317
  clearTimeout(timer);
318
+ cleanupAbort?.();
310
319
  reject(new Error(`Claude session closed without result${stderrSuffix()}`));
311
320
  });
312
321
  child.on("error", (err) => {
313
322
  if (settled) return;
314
323
  settled = true;
315
324
  clearTimeout(timer);
325
+ cleanupAbort?.();
316
326
  reject(new Error(`Claude session spawn error: ${err.message}`));
317
327
  });
318
328
 
@@ -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,7 +258,7 @@ 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);
@@ -1,27 +1,11 @@
1
1
  /**
2
- * stream-json 어댑터 — Anthropic(Claude) provider 의 입력/출력 변환.
2
+ * Claude CLI stream-json adapter.
3
3
  *
4
- * 모듈은 부활의 유일한 본질적 신규 코드다. 두 방향을 분리된 순수 함수로 둔다:
5
- * - 입력: 서버 GenerateRequest payload(coldInput/coldHistory/reuseInput)
6
- * → `claude -p --input-format stream-json` 의 JSONL 라인.
7
- * - 출력: `claude -p --output-format stream-json --verbose` 의 JSONL 라인
8
- * → qgrid 서버 delta(onDelta) + 최종 result. (U3, 같은 파일 하단)
4
+ * Input: qgrid history/input -> Claude JSONL stdin.
5
+ * Output: Claude JSONL stdout -> qgrid deltas + final result metadata.
9
6
  *
10
- * 중요한 계약:
11
- * - 입력은 AI SDK LanguageModelV3Message[] 아니라 **서버측 GenerateRequest** 다.
12
- * ai-sdk 가 이미 QueryInput.history(JsonValue[]) 로 변환했고, decideConvRouting 이
13
- * coldInput/coldHistory/reuseInput 으로 만든 뒤 dispatcher 에 도달한다.
14
- * - content 는 user/assistant 모두 block 배열 `[{type:"text",text}]` 로 정규화한다.
15
- * 문자열 content 는 일부 user 단발만 통과하고 history replay 에서 깨진다
16
- * (`W is not an Object (evaluating '"tool_use_id" in W')`).
17
- * - stream-json 의 user/assistant 는 비대칭이다: assistant 는 history context 로 push 되고
18
- * **user 는 새 prompt 로 enqueue(=실행)된다.** 따라서 cold 경로에서 과거 대화(user/assistant/
19
- * tool)를 그대로 줄로 늘어놓으면 과거 user/tool-output 이 실행 prompt 로 재실행된다. 이를 피하려
20
- * cold history 는 **단일 assistant context 텍스트로 평탄화**하고, 실행 가능한 `type:"user"` 줄은
21
- * **마지막 input 하나뿐**이 되도록 한다. full-fidelity role replay 가 필요하면 session resume
22
- * (reuseInput) 또는 JSONL backstop 으로 간다.
23
- * - tool 결과는 native Anthropic tool_result 블록을 새로 만들지 않고, 기존 OpenAI 와
24
- * 동일하게 평탄화된 text 로 처리한다(emulation 공통 계약).
7
+ * Previous conversation items are flattened into one assistant context line.
8
+ * The only executable user line is the current input.
25
9
  */
26
10
 
27
11
  import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
@@ -30,11 +14,7 @@ import { type ProviderTokenUsageBreakdown } from "../common/provider-dispatcher"
30
14
 
31
15
  // ── 입력 어댑터 ────────────────────────────────────────────────────
32
16
 
33
- // Claude stream-json 입력 한 줄(JSONL). content 항상 block 배열.
34
- //
35
- // SDK envelope 필드(uuid / session_id / parent_tool_use_id)는 **여기서 만들지 않는다**.
36
- // 그 값들은 세션을 발급/소유하는 claude-session 의 책임이다. 이 모듈은 role/content 변환만 하는
37
- // 순수 함수로 두고, claude-session 이 직렬화 직전에 envelope 를 decorate 한다. 책임 분리.
17
+ // Claude stream-json 입력 한 줄(JSONL). Envelope fields are added in claude-session.
38
18
  export interface ClaudeStreamJsonLine {
39
19
  type: "user" | "assistant";
40
20
  message: {
@@ -43,7 +23,6 @@ export interface ClaudeStreamJsonLine {
43
23
  };
44
24
  }
45
25
 
46
- // UserInput[] 에서 text 만 뽑아 하나의 문자열로. (image/skill/mention 등은 현재 범위 밖 — text 위주)
47
26
  function userInputToText(input: Array<UserInput>): string {
48
27
  return input
49
28
  .filter((i): i is Extract<UserInput, { type: "text" }> => i.type === "text")
@@ -51,12 +30,11 @@ function userInputToText(input: Array<UserInput>): string {
51
30
  .join("\n");
52
31
  }
53
32
 
54
- // content 를 항상 block 배열로 정규화(문자열 content 는 history replay 에서 깨짐).
55
33
  function textBlock(text: string): Array<{ type: "text"; text: string }> {
56
34
  return [{ type: "text", text }];
57
35
  }
58
36
 
59
- // codex ResponseItem(coldHistory 의 한 항목) 형태 판별용 최소 shape.
37
+ // codex ResponseItem(coldHistory item) 최소 shape.
60
38
  // extractPromptAndHistory(ai-sdk/utils.ts) 가 만드는 형식:
61
39
  // { type:"message", role:"user", content:[{type:"input_text", text}] }
62
40
  // { type:"message", role:"assistant", content:[{type:"output_text", text}] }
@@ -68,8 +46,6 @@ function asObject(v: JsonValue | undefined): ResponseItem | null {
68
46
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as ResponseItem) : null;
69
47
  }
70
48
 
71
- // codex message content([{type:input_text|output_text, text}]) 에서 text 추출.
72
- // 여러 content 블록은 줄바꿈으로 구분(token-adjacent 붙음 방지).
73
49
  function responseItemText(item: ResponseItem): string {
74
50
  const content = item.content;
75
51
  if (!Array.isArray(content)) return "";
@@ -82,11 +58,6 @@ function responseItemText(item: ResponseItem): string {
82
58
  .join("\n");
83
59
  }
84
60
 
85
- /**
86
- * cold history(codex ResponseItem[])를 **단일 평탄 텍스트**로 변환한다.
87
- * user/assistant/tool 모두 라벨 붙은 한 덩어리로 — 실행되지 않는 assistant context 로 들어간다.
88
- * (cold 의 user/tool-output 을 type:"user" 로 내보내면 새 prompt 로 재실행되므로 금지.)
89
- */
90
61
  function flattenColdHistory(coldHistory: Array<JsonValue>): string {
91
62
  const parts: Array<string> = [];
92
63
  for (const raw of coldHistory) {
@@ -111,30 +82,13 @@ function flattenColdHistory(coldHistory: Array<JsonValue>): string {
111
82
  return parts.join("\n");
112
83
  }
113
84
 
114
- /**
115
- * 서버 GenerateRequest payload 를 Claude stream-json JSONL 라인 배열로 변환한다.
116
- *
117
- * @param opts.coldHistory cold 경로의 codex ResponseItem 배열(JsonValue[]). resume 경로면 미사용.
118
- * @param opts.input 실행할 input — cold 면 coldInput, resume 면 reuseInput.
119
- * @param opts.isResume resume 경로 여부. true 면 history 재주입 없이 input(delta)만.
120
- *
121
- * 출력 규칙:
122
- * - 실행 가능한 `type:"user"` 줄은 **항상 정확히 1개** — 마지막 input.
123
- * - resume: user(delta) 1줄만. thread 가 이전 turn 을 누적하므로 history 미포함.
124
- * - cold: coldHistory 전체를 **단일 assistant context 텍스트**로 평탄화(실행 안 됨) + user(input) 1줄.
125
- * user/assistant/tool 모두 평탄화되므로 과거 user/tool-output 이 재실행되지 않는다.
126
- * full-fidelity role replay 가 필요하면 session resume 또는 JSONL backstop(범위 밖).
127
- * - tool(function_call/output): native 블록 없이 평탄 텍스트로.
128
- */
129
85
  export function buildStreamJsonInput(opts: {
130
86
  coldHistory?: Array<JsonValue>;
131
87
  input: Array<UserInput>;
132
- isResume: boolean;
133
88
  }): Array<ClaudeStreamJsonLine> {
134
89
  const lines: Array<ClaudeStreamJsonLine> = [];
135
90
 
136
- // cold 경로: history 단일 assistant context 로 평탄화(실행 안 됨).
137
- if (!opts.isResume && opts.coldHistory && opts.coldHistory.length > 0) {
91
+ if (opts.coldHistory && opts.coldHistory.length > 0) {
138
92
  const flattened = flattenColdHistory(opts.coldHistory);
139
93
  if (flattened) {
140
94
  lines.push({
@@ -147,7 +101,6 @@ export function buildStreamJsonInput(opts: {
147
101
  }
148
102
  }
149
103
 
150
- // 실행할 input — resume/cold 공통, 항상 마지막의 단일 user 줄.
151
104
  lines.push({
152
105
  type: "user",
153
106
  message: { role: "user", content: textBlock(userInputToText(opts.input)) },
@@ -156,26 +109,14 @@ export function buildStreamJsonInput(opts: {
156
109
  return lines;
157
110
  }
158
111
 
159
- // JSONL 문자열(claude stdin 으로 흘릴 형태)로 직렬화.
160
112
  export function serializeStreamJsonInput(lines: Array<ClaudeStreamJsonLine>): string {
161
113
  return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
162
114
  }
163
115
 
164
116
  // ── 출력 어댑터 ────────────────────────────────────────────────────
165
117
  //
166
- // claude -p --output-format stream-json --verbose stdout(JSONL)을
167
- // qgrid 서버 delta(onDelta) + 최종 result 로 변환한다.
168
- //
169
- // 계약:
170
- // - 이 어댑터는 qgrid **서버** 계층이다. AI SDK stream part(text-delta 등)는
171
- // packages/ai-sdk(client provider)가 만든다 — 여기서 만들지 않는다.
172
- // - text_delta → onDelta(text). structured output 일 때 input_json_delta.partial_json
173
- // 조각도 onDelta(text) 로 흘린다(client provider 가 partialObjectStream 으로 누적).
174
- // - 최종 result → structured_output(우선) 또는 result(폴백, 자연어 가능) + usage + 종료.
175
- // - usage 는 input/cache_creation/cache_read/output 4 카테고리를 받아 TokenUsageBreakdown 으로.
176
- // payload 크기 비교는 3 카테고리 합으로(과거 학습 — input 단독 비교 무의미).
118
+ // Claude stdout(JSONL)을 qgrid deltas + final result 로 변환한다.
177
119
 
178
- // claude stream-json 출력에서 우리가 보는 usage shape(부분). Anthropic 네이티브 필드.
179
120
  interface ClaudeUsage {
180
121
  input_tokens?: number;
181
122
  output_tokens?: number;
@@ -183,7 +124,6 @@ interface ClaudeUsage {
183
124
  cache_read_input_tokens?: number;
184
125
  }
185
126
 
186
- // 최종 result 파싱 산출물. dispatcher(U1)가 GenerateResult 조립에 쓴다.
187
127
  export interface ClaudeStreamResult {
188
128
  text: string;
189
129
  usage: ProviderTokenUsageBreakdown;
@@ -220,8 +160,6 @@ function toUsageBreakdown(u: ClaudeUsage): ProviderTokenUsageBreakdown {
220
160
  };
221
161
  }
222
162
 
223
- // result.result 의 코드펜스(```json ... ```) 제거. structured_output 이 없을 때만 쓴다.
224
- // 앞뒤 공백/개행을 먼저 trim 해 `\n```json\n...\n```\n` 같은 형태도 깨끗이 제거.
225
163
  function stripCodeFence(s: string): string {
226
164
  return s
227
165
  .trim()
@@ -237,26 +175,18 @@ function structuredOutputToolUseText(j: ResponseItem): string | undefined {
237
175
  for (const raw of content) {
238
176
  const block = asObject(raw);
239
177
  if (!block) continue;
240
- if (block.type === "tool_use" && block.name === "StructuredOutput" && block.input !== undefined) {
178
+ if (
179
+ block.type === "tool_use" &&
180
+ block.name === "StructuredOutput" &&
181
+ block.input !== undefined
182
+ ) {
241
183
  return JSON.stringify(block.input);
242
184
  }
243
185
  }
244
186
  return undefined;
245
187
  }
246
188
 
247
- /**
248
- * stream-json 출력 라인 하나를 처리한다. 순수 함수 — side effect 는 onDelta 콜백뿐.
249
- *
250
- * @param opts.structuredOutput structured output(jsonSchema) 모드 여부.
251
- * structured 모드에서 Claude 는 자연어 text_delta 와 input_json_delta.partial_json 을
252
- * **함께** 흘릴 수 있다(PoC 확인). 이때 text_delta 를 그대로 onDelta 로 보내면 client 의
253
- * streamObject 파서가 prose 를 JSON 앞에서 만나 깨진다. 그래서:
254
- * - structured 모드: text_delta 무시, partial_json 만 onDelta.
255
- * - text 모드: text_delta 만 onDelta (partial_json 은 안 옴).
256
- *
257
- * @returns result 이벤트면 ClaudeStreamResult, 그 외(delta/system 등)면 null.
258
- * 호출부는 라인 스트림을 돌며 이 함수를 호출하고, non-null 이 나오면 종료.
259
- */
189
+ // Processes one Claude stdout JSONL line. Returns final result on result event.
260
190
  export function handleStreamJsonLine(
261
191
  line: string,
262
192
  onDelta: (text: string) => void,
@@ -271,23 +201,19 @@ export function handleStreamJsonLine(
271
201
  try {
272
202
  j = JSON.parse(trimmed) as { [key: string]: JsonValue | undefined };
273
203
  } catch {
274
- // 깨진 JSON 라인은 graceful skip.
275
204
  return null;
276
205
  }
277
206
 
278
- // 점진 delta: stream_event > content_block_delta > {text_delta | input_json_delta}.
279
207
  if (j.type === "stream_event") {
280
208
  const event = asObject(j.event);
281
209
  if (event && event.type === "content_block_delta") {
282
210
  const delta = asObject(event.delta);
283
211
  if (delta) {
284
212
  if (structuredOutput) {
285
- // structured: JSON 조각만. 자연어 text_delta 는 버린다(streamObject 파서 보호).
286
213
  if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
287
214
  onDelta(delta.partial_json);
288
215
  }
289
216
  } else {
290
- // text: 자연어 delta 만.
291
217
  if (delta.type === "text_delta" && typeof delta.text === "string") {
292
218
  onDelta(delta.text);
293
219
  }
@@ -297,13 +223,17 @@ export function handleStreamJsonLine(
297
223
  return null;
298
224
  }
299
225
 
300
- if (j.type === "assistant" && structuredOutput && state && state.structuredOutputText === undefined) {
226
+ if (
227
+ j.type === "assistant" &&
228
+ structuredOutput &&
229
+ state &&
230
+ state.structuredOutputText === undefined
231
+ ) {
301
232
  const text = structuredOutputToolUseText(j);
302
233
  if (text !== undefined) state.structuredOutputText = text;
303
234
  return null;
304
235
  }
305
236
 
306
- // 최종 result.
307
237
  if (j.type === "result") {
308
238
  let text: string;
309
239
  const preservedStructuredOutput = structuredOutput ? state?.structuredOutputText : undefined;
@@ -317,18 +247,11 @@ export function handleStreamJsonLine(
317
247
 
318
248
  const usage = toUsageBreakdown((asObject(j.usage) ?? {}) as ClaudeUsage);
319
249
  const quotaExhausted = text.startsWith("You've hit");
320
- // 에러 판정: is_error true, terminal model_error,
321
- // 또는 subtype 이 있고 "success" 가 아니면(error_during_execution / error_max_turns /
322
- // error_max_budget_usd / error_max_structured_output_retries 등) 에러로 본다.
323
250
  const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
324
- const structuredMaxTurnsCompleted =
325
- preservedStructuredOutput !== undefined &&
326
- (subtype === "error_max_turns" || j.terminal_reason === "max_turns");
327
251
  const isError =
328
- !structuredMaxTurnsCompleted &&
329
- (j.is_error === true ||
330
- j.terminal_reason === "model_error" ||
331
- (subtype !== undefined && subtype !== "success"));
252
+ j.is_error === true ||
253
+ j.terminal_reason === "model_error" ||
254
+ (subtype !== undefined && subtype !== "success");
332
255
 
333
256
  return {
334
257
  text,