@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,23 +1,14 @@
1
1
  /**
2
- * AnthropicDispatcher — Claude(Anthropic) provider 의 ProviderDispatcher 구현.
2
+ * AnthropicDispatcher
3
3
  *
4
- * OpenAIDispatcher 패턴을 미러링하되, worker pool 대신 요청별 fresh spawn(U4 runClaudeSession)을
5
- * 쓴다. 토큰은 자체 풀(Map) 관리하고 least-used round-robin 으로 고른다.
4
+ * OpenAIDispatcher 달리, worker pool 두는 대신 요청별 fresh spawn
5
+ * 토큰은 인메모리(MAP)으로 관리하고 least-used, round-robin 으로 고른다
6
6
  *
7
- * 멀티턴 / 동시성 계약:
8
- * - **eligibility compatibilityKey(system+model+schema)** 판정한다. systemHash 아니다.
9
- * GenerateRequest.reuse 들어오면 threadId(=claude session-id)에 대해 dispatcher-local
10
- * `sessionCompat` 저장해 호환키와 이번 요청 호환키를 비교 일치 시 resume, 불일치/없음 시
11
- * cold(새 session-id).
12
- * - **compat 는 threadCoord 로 왕복하지 않는다.** sessionCompat 는 이 dispatcher 인스턴스 메모리에만
13
- * 산다(TTL sweep + 토큰 제거 시 정리). threadCoord.systemHash 는 상위 issueConvContext 가 따로
14
- * 채우지만 Anthropic resume eligibility 판정에는 쓰이지 않는다.
15
- * - **락 소유권**: claude session-id 직렬화는 U4 `runClaudeSession` 이 단독 소유한다.
16
- * dispatcher 는 resume eligibility / token ownership 만 판정하고 `withSessionLock` 으로 감싸지 않는다.
17
- * 여기서 다시 감싸면 U4 내부 락과 같은 session-id 로 재진입해 deadlock 이 난다.
18
- * cold 첫 호출 경쟁을 따로 막지 않는 근거는 run() 의 락 주석 참조(IF/owner scope 의 순차 호출 전제).
19
- * - 발급한 session-id 와 호환키를 GenerateResult.threadCoord(threadId=session-id, workerId=tokenId,
20
- * epoch=0)로 올려, 상위(issueConvContext)가 클라에 회송할 좌표를 만든다.
7
+ * - 모든 요청은 fresh `--session-id` 로 실행
8
+ * - 멀티턴 문맥은 클라이언트가 호출 보내는 full history 를 평탄화해 전달한다.
9
+ * - 발급한 session-id 를 GenerateResult.threadCoord(threadId=session-id, workerId=tokenId,
10
+ * epoch=0)로 올려, 상위(issueConvContext)가 클라에 회송할 좌표를 만든다. 다음 turn 좌표를
11
+ * 회송해도 Anthropic 라우팅에는 쓰지 않는다.
21
12
  */
22
13
 
23
14
  import { getLogger } from "@logtape/logtape";
@@ -31,12 +22,14 @@ import {
31
22
  type GenerateStreamCallbacks,
32
23
  type ProviderDispatcher,
33
24
  } from "../common/provider-dispatcher";
34
- import { canonicalAnthropicModel } from "./anthropic-constants";
35
- import { compatibilityKey, makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
25
+ import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
26
+ import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
36
27
 
37
28
  const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
38
29
 
39
- const DEFAULT_TIMEOUT_MS = 120_000;
30
+ // 1M context 정상 생성은 실측상 100s+까지 간다. 240s는 완충 장치일 뿐,
31
+ // 거부+거대 system 재생성 지연을 완전히 해결하는 값은 아니다.
32
+ const DEFAULT_TIMEOUT_MS = 240_000;
40
33
  // access token 만료 임박 임계 — 기존 standalone 경로(qgrid.dispatcher.ts)와 동일(60s).
41
34
  const REFRESH_SAFETY_MS = 60_000;
42
35
 
@@ -46,56 +39,12 @@ interface PooledToken {
46
39
  credentials: AnthropicCredentials;
47
40
  }
48
41
 
49
- // QgridFrame.refreshToken 은 TokenSubsetA(특히 provider)를 받아 TokenModel.save 에 넘긴다.
50
- // anthropic 풀의 토큰은 항상 provider="anthropic" 이므로 refresh 시 이 값을 고정으로 채운다.
51
- // (provider 누락 시 save 실패 → 만료된 access token 으로 진행하는 버그가 난다.)
52
- const ANTHROPIC_PROVIDER = "anthropic" as const;
53
-
54
- // session-id → compat 매핑 TTL. 서버 thread idle TTL(10분)과 정합 — 그보다 오래된 세션은
55
- // 어차피 resume 불가하므로 폐기해도 안전(sessionCompat 무한 증가 방지).
56
- const SESSION_COMPAT_TTL_MS = 10 * 60 * 1000;
57
-
58
- interface CompatEntry {
59
- compat: string;
60
- tokenId: number;
61
- lastUsedAt: number;
62
- }
63
-
64
- function isRetryableResumeFailure(error: unknown): boolean {
65
- const message = error instanceof Error ? error.message : String(error);
66
- const lower = message.toLowerCase();
67
- if (
68
- lower.includes("timeout") ||
69
- lower.includes("aborted") ||
70
- lower.includes("spawn error") ||
71
- lower.includes("quota") ||
72
- lower.includes("you've hit") ||
73
- lower.includes("unauthorized") ||
74
- lower.includes("authentication") ||
75
- lower.includes("invalid oauth") ||
76
- lower.includes("permission denied") ||
77
- lower.includes("max_turns") ||
78
- lower.includes("max-turns") ||
79
- lower.includes("schema") ||
80
- lower.includes("model_error") ||
81
- lower.includes("model error")
82
- ) {
83
- return false;
84
- }
85
- return lower.includes("closed without result") || /\b(resume|transcript)\b/.test(lower);
86
- }
87
-
88
42
  export class AnthropicDispatcher implements ProviderDispatcher {
89
- // tokenId → 풀 항목. start()/token 이벤트로 채운다.
43
+ // tokenId → 풀 항목. start()/token 이벤트로 채움
90
44
  private tokenPool = new Map<number, PooledToken>();
91
- // tokenId 별 사용 카운터(least-used RR). name 이 아니라 id 기준 — 이름 충돌 안전.
45
+ // tokenId 별 사용 카운터(least-used RR). name 이 아니라 id 기준 — 이름 충돌 안전
92
46
  private requestCounts = new Map<number, number>();
93
47
  private rrIndex = 0;
94
- // claude session-id → 그 세션을 만든 요청의 호환키(+tokenId/lastUsedAt). resume eligibility 판정.
95
- // TTL sweep + 토큰 제거 시 정리로 무한 증가 방지.
96
- private sessionCompat = new Map<string, CompatEntry>();
97
-
98
- // ── lifecycle ──────────────────────────────────────────────────
99
48
 
100
49
  async start(): Promise<void> {
101
50
  // OpenAIDispatcher.start() 와 동일하게 DB 에서 기존 anthropic 토큰을 self-bootstrap 한다.
@@ -109,13 +58,12 @@ export class AnthropicDispatcher implements ProviderDispatcher {
109
58
  name: t.name,
110
59
  credentials: t.credentials as AnthropicCredentials,
111
60
  });
61
+ logger.info(`worker spawned: ${t.name}`);
112
62
  }
113
- logger.info(`anthropic dispatcher started (${this.tokenPool.size} tokens)`);
114
63
  }
115
64
 
116
65
  async stop(): Promise<void> {
117
66
  this.tokenPool.clear();
118
- this.sessionCompat.clear();
119
67
  // lifecycle 정합: RR 카운터/인덱스도 비워 stop→start 재등록 시 이전 카운트가 안 남게 한다.
120
68
  this.requestCounts.clear();
121
69
  this.rrIndex = 0;
@@ -128,9 +76,9 @@ export class AnthropicDispatcher implements ProviderDispatcher {
128
76
 
129
77
  // DB active anthropic 토큰 목록으로 풀을 재동기화한다(periodic reconcile/재연결용).
130
78
  // LISTEN/NOTIFY 가 끊긴 동안 유실된 추가/삭제/비활성화를 DB 기준으로 다시 맞춘다.
131
- // 이벤트 핸들러를 재사용해 identity-change / sessionCompat 정리가 일관되게 적용되도록 한다:
132
- // - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved (compat 정리 포함)
133
- // - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated(identity 변경 감지)
79
+ // 이벤트 핸들러를 재사용해 갱신이 일관되게 적용되도록 한다:
80
+ // - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved
81
+ // - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated
134
82
  replaceTokens(
135
83
  rows: Array<{ id: number; name: string; credentials: AnthropicCredentials }>,
136
84
  ): void {
@@ -146,44 +94,25 @@ export class AnthropicDispatcher implements ProviderDispatcher {
146
94
  }
147
95
  }
148
96
 
149
- // ── token events (token-subscriber 가 호출) ──────────────────────
150
-
97
+ // token events (token-subscriber 가 호출)
151
98
  onTokenAdded(id: number, name: string, credentials: AnthropicCredentials): void {
152
99
  this.tokenPool.set(id, { id, name, credentials });
153
100
  }
101
+
154
102
  onTokenUpdated(id: number, name: string, credentials: AnthropicCredentials): void {
155
- // access/refresh token rotation(같은 계정)이면 in-place 갱신으로 세션을 유지한다.
156
- // 하지만 accountUuid 가 바뀐 재로그인/계정 교체면, 같은 tokenId 아래 살아남은 sessionCompat 가
157
- // 이전 계정으로 만든 session-id 를 새 계정 credentials 로 resume 시키는 격리 위반이 된다.
158
- // OpenAI dispatcher 가 login identity 변경을 worker restart 로 구분하는 것과 동일한 처리:
159
- // identity 가 바뀌면 그 tokenId 의 세션 compat 을 폐기해 이후 호출이 cold 로 떨어지게 한다.
160
- const existing = this.tokenPool.get(id);
161
- const identityChanged =
162
- existing !== undefined && existing.credentials.accountUuid !== credentials.accountUuid;
163
- if (identityChanged) this.clearSessionCompatFor(id);
164
103
  this.tokenPool.set(id, { id, name, credentials });
165
104
  }
105
+
166
106
  onTokenRemoved(id: number): void {
167
107
  this.tokenPool.delete(id);
168
108
  this.requestCounts.delete(id);
169
- // 이 토큰으로 만든 세션의 compat 엔트리도 정리.
170
- this.clearSessionCompatFor(id);
171
109
  }
172
110
 
173
- // 특정 tokenId 로 만든 모든 sessionCompat 엔트리 폐기(토큰 제거 / identity 변경 시 공통).
174
- private clearSessionCompatFor(tokenId: number): void {
175
- for (const [sid, e] of this.sessionCompat) {
176
- if (e.tokenId === tokenId) this.sessionCompat.delete(sid);
177
- }
178
- }
179
-
180
- // ── token selection (least-used RR, tokenId 기준) ────────────────
181
-
182
111
  private countOf(id: number): number {
183
112
  return this.requestCounts.get(id) ?? 0;
184
113
  }
185
114
 
186
- // cold 경로: least-used RR 로 토큰을 고른다(동점이면 rrIndex 로 회전).
115
+ // 요청마다 least-used RR 로 토큰을 고른다(동점이면 rrIndex 로 회전).
187
116
  private selectToken(): PooledToken | null {
188
117
  const rows = [...this.tokenPool.values()];
189
118
  if (rows.length === 0) return null;
@@ -194,37 +123,19 @@ export class AnthropicDispatcher implements ProviderDispatcher {
194
123
  return this.charge(picked);
195
124
  }
196
125
 
197
- // resume 경로: 세션을 만든 토큰을 id 로 고정 선택한다. 풀에 없으면 null(→ cold fallback).
198
- // resume 세션은 그 토큰의 CLAUDE_CONFIG_DIR 에 transcript 가 묶여 있어, 다른 토큰으로 resume 하면
199
- // transcript 가 없어 깨진다. 그래서 RR 을 쓰지 않고 소유 토큰을 그대로 잡아야 한다.
200
- private pickToken(id: number): PooledToken | null {
201
- const token = this.tokenPool.get(id);
202
- return token ? this.charge(token) : null;
203
- }
204
-
205
126
  // 선택된 토큰의 사용 카운트를 await 전에 선반영(동시 요청이 다른 토큰을 고르도록).
206
127
  private charge(token: PooledToken): PooledToken {
207
128
  this.requestCounts.set(token.id, this.countOf(token.id) + 1);
208
129
  return token;
209
130
  }
210
131
 
211
- // 만료된 compat 엔트리 정리(lazy sweep — generate 진입 시 호출).
212
- private sweepSessionCompat(): void {
213
- const now = Date.now();
214
- for (const [sid, e] of this.sessionCompat) {
215
- if (now - e.lastUsedAt > SESSION_COMPAT_TTL_MS) this.sessionCompat.delete(sid);
216
- }
217
- }
218
-
219
- // ── generate ──────────────────────────────────────────────────
220
-
221
132
  async generate(req: GenerateRequest): Promise<GenerateResult> {
222
133
  return this.run(req, () => {});
223
134
  }
224
135
 
225
136
  async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
226
137
  try {
227
- const result = await this.run(req, cb.onDelta);
138
+ const result = await this.run(req, cb.onDelta, { includePartialMessages: true });
228
139
  cb.onThreadId?.(result.threadCoord.threadId);
229
140
  cb.onComplete(result);
230
141
  } catch (e) {
@@ -233,47 +144,22 @@ export class AnthropicDispatcher implements ProviderDispatcher {
233
144
  }
234
145
 
235
146
  // generate/generateStream 공통 실행. onDelta 는 스트림이면 점진 방출, 비스트림이면 no-op.
236
- private async run(req: GenerateRequest, onDelta: (t: string) => void): Promise<GenerateResult> {
237
- this.sweepSessionCompat();
238
-
239
- // model 정규화를 dispatcher 진입점에서 한 번 한다: compat / result.model / runClaudeSession 전부
147
+ private async run(
148
+ req: GenerateRequest,
149
+ onDelta: (t: string) => void,
150
+ opts?: { includePartialMessages?: boolean },
151
+ ): Promise<GenerateResult> {
152
+ // model 정규화를 dispatcher 진입점에서 한 번 한다: result.model / runClaudeSession 전부
240
153
  // canonical(prefix 없는 cost 키) 을 쓰게 통일. 미지정이면 ANTHROPIC_DEFAULT_MODEL — qgrid.dispatcher
241
154
  // 의 "sonnet" 별칭 우회 차단. 정규화 규칙은 fallback 경로와 공유(canonicalAnthropicModel).
155
+ assertSupportedOneMillionSuffix(req.model);
242
156
  const model = canonicalAnthropicModel(req.model);
243
157
  const jsonSchema =
244
158
  req.outputSchema !== undefined ? JSON.stringify(req.outputSchema) : undefined;
245
- const compat = compatibilityKey({ system: req.systemPrompt, model, outputSchema: jsonSchema });
246
-
247
- // ── resume eligibility + 토큰 선택 (resume 는 세션 소유 토큰으로만) ──
248
- // resume 가 성립하려면 세 조건이 모두 참이어야 한다:
249
- // (a) reuse.threadId 의 저장된 호환키 == 이번 요청 호환키 (모델/system/schema 변경 오염 방지)
250
- // (b) 저장된 tokenId == reuse.workerId (coord 왕복 무결성) 이고
251
- // (c) 그 토큰이 아직 풀에 살아있음.
252
- // 하나라도 어긋나면 resume 불가 → cold 로 떨어지고 RR 로 새 토큰을 고른다.
253
- // resume 일 때는 반드시 세션을 만든 그 토큰(stored.tokenId)을 고정 선택한다 — RR 로 다른 토큰을 잡으면
254
- // 그 토큰의 CLAUDE_CONFIG_DIR 에 transcript 가 없어 resume 이 깨진다.
255
- const candidate = req.reuse?.threadId;
256
- const stored = candidate !== undefined ? this.sessionCompat.get(candidate) : undefined;
257
- const ownerMatches = stored !== undefined && stored.tokenId === req.reuse?.workerId;
258
- const resumeToken =
259
- stored?.compat === compat && ownerMatches ? this.pickToken(stored.tokenId) : null;
260
159
 
261
- const isResume = resumeToken !== null;
262
- const resumeSessionId = isResume ? candidate : undefined;
263
-
264
- // resume 면 소유 토큰 고정, 아니면 cold RR 선택.
265
- const token = resumeToken ?? this.selectToken();
160
+ const token = this.selectToken();
266
161
  if (!token) throw new Error("No anthropic tokens available");
267
162
 
268
- // 락 소유권:
269
- // session-id 단위 직렬화 락은 **U4 runClaudeSession 이 단독 소유**한다. dispatcher 가 여기서
270
- // withSessionLock(resumeSessionId) 으로 한 번 더 감싸면, exec() 안의 runClaudeSession 이 같은
271
- // session-id 로 락에 재진입하는데 withSessionLock 은 reentrant 가 아니라 자기 자신을 기다리는
272
- // deadlock 이 난다(첫 resume 호출이 영구 정지). U1/U5 dispatcher 는 token ownership / compat 만
273
- // 판정하고, transcript 직렬화는 U4 락에 위임한다.
274
- // cold 첫-호출 동시성을 dispatcher 가 따로 막지 않는 근거: sessionKey 는 서버로 오지 않아(클라
275
- // store 관리, 좌표만 회송) cold 별 락의 입력 자체가 없고, 현재 IF/owner 는 LLM 호출을 전부 순차로
276
- // 돌려 동시 첫 호출이 나지 않는다(2026-06-06 L21/L248). 병렬화/서버전달로 바뀌면 재검토.
277
163
  const exec = async (): Promise<GenerateResult> => {
278
164
  // 만료 임박 토큰 preemptive refresh(기존 standalone 경로와 동일). 실패해도 진행.
279
165
  let accessToken = token.credentials.accessToken;
@@ -289,7 +175,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
289
175
  // refreshToken(TokenSubsetA) 는 id/provider/credentials/name 만 사용하므로 그 4개만 넘긴다.
290
176
  accessToken = await QgridFrame.refreshToken({
291
177
  id: token.id,
292
- provider: ANTHROPIC_PROVIDER,
178
+ provider: "anthropic",
293
179
  name: token.name,
294
180
  credentials: token.credentials,
295
181
  } as Parameters<typeof QgridFrame.refreshToken>[0]);
@@ -298,52 +184,39 @@ export class AnthropicDispatcher implements ProviderDispatcher {
298
184
  }
299
185
  }
300
186
 
301
- const runSession = (resume: boolean) => {
302
- logger.info(`→ ${token.name} (model: ${model}, ${resume ? "resume" : "cold"})`);
303
- return runClaudeSession(
304
- {
305
- tokenId: token.id,
306
- token: accessToken,
307
- model,
308
- system: req.systemPrompt,
309
- jsonSchema,
310
- effort: req.effort,
311
- timeoutMs: DEFAULT_TIMEOUT_MS,
312
- coldHistory: resume ? undefined : req.coldHistory,
313
- input: resume && req.reuseInput ? req.reuseInput : req.coldInput,
314
- resumeSessionId: resume ? resumeSessionId : undefined,
315
- abortSignal: req.abortSignal,
316
- },
317
- onDelta,
318
- );
319
- };
320
-
321
- let session;
322
- try {
323
- session = await runSession(isResume);
324
- } catch (e) {
325
- if (!isResume || !isRetryableResumeFailure(e)) throw e;
326
- if (candidate) this.sessionCompat.delete(candidate);
327
- logger.warn(`resume failed for ${token.name}; retrying cold once: ${(e as Error).message}`);
328
- session = await runSession(false);
329
- }
187
+ logger.info(`→ ${token.name} (model: ${model})`);
188
+ const session = await runClaudeSession(
189
+ {
190
+ tokenId: token.id,
191
+ token: accessToken,
192
+ model,
193
+ system: req.systemPrompt,
194
+ jsonSchema,
195
+ effort: req.effort,
196
+ timeoutMs: DEFAULT_TIMEOUT_MS,
197
+ coldHistory: req.coldHistory,
198
+ input: req.coldInput,
199
+ abortSignal: req.abortSignal,
200
+ includePartialMessages: opts?.includePartialMessages,
201
+ },
202
+ onDelta,
203
+ );
330
204
 
331
205
  if (session.quotaExhausted) throw new Error(`quota exhausted (${token.name})`);
332
206
  if (session.isError) {
333
- // text 가 비어 있을 수 있으므로 진단 가능한 형태로 메시지를 만든다(subtype 등은 raw 로그 참조).
334
- const detail = session.text || `empty text, outputTokens=${session.usage.outputTokens}`;
335
- throw new Error(`claude error (${token.name}): ${detail}`);
207
+ // 진단(SON-495): isError 판정 사유(subtype/terminal_reason)를 메시지 앞에 드러낸다.
208
+ // 그동안 detail session.text(완전한 JSON 본문)뿐이라 "schema 위반인지 / max retries 인지 /
209
+ // max turns 인지"를 구분할 수 없었다. text 는 진단에 필요한 만큼만 잘라 덧붙인다.
210
+ const reason =
211
+ session.subtype ?? session.terminalReason ?? (session.isError ? "is_error" : "unknown");
212
+ const body = session.text
213
+ ? session.text.length > 500
214
+ ? `${session.text.slice(0, 500)}…(${session.text.length} chars)`
215
+ : session.text
216
+ : `empty text, outputTokens=${session.usage.outputTokens}`;
217
+ throw new Error(`claude error (${token.name}) [${reason}]: ${body}`);
336
218
  }
337
219
 
338
- // 발급한 session-id 의 호환키 저장(다음 resume 판정용). compat 는 여기 dispatcher-local 에만
339
- // 보존한다 — threadCoord.systemHash 로 왕복하지 않는다. 상위 issueConvContext 가 systemHash 를
340
- // 따로 채우지만 Anthropic resume eligibility 는 이 sessionCompat 가 단독 판정.
341
- this.sessionCompat.set(session.sessionId, {
342
- compat,
343
- tokenId: token.id,
344
- lastUsedAt: Date.now(),
345
- });
346
-
347
220
  return {
348
221
  text: session.text,
349
222
  tokenName: token.name,
@@ -4,62 +4,18 @@ import { describe, expect, it } from "vitest";
4
4
 
5
5
  import { ANTHROPIC_CONFIG_DIR_BASE, anthropicConfigDir } from "./anthropic-constants";
6
6
  import {
7
+ applyOneMillionSuffix,
7
8
  buildClaudeArgs,
8
- compatibilityKey,
9
9
  decorateAndSerialize,
10
10
  ensureConfigDir,
11
11
  makeAnthropicWorkerId,
12
+ oneMillionEnv,
13
+ runClaudeSession,
14
+ structuredOutputRetriesEnv,
12
15
  withSessionLock,
13
16
  } from "./claude-session";
14
17
  import { buildStreamJsonInput } from "./stream-json-adapter";
15
18
 
16
- describe("compatibilityKey (P1-5)", () => {
17
- it("같은 system+model+schema → 같은 키", () => {
18
- const a = compatibilityKey({ system: "s", model: "claude-sonnet-4-6" });
19
- const b = compatibilityKey({ system: "s", model: "claude-sonnet-4-6" });
20
- expect(a).toBe(b);
21
- });
22
-
23
- it("model 다르면 다른 키 (sessionKey 재사용 오염 방지)", () => {
24
- const a = compatibilityKey({ system: "s", model: "claude-sonnet-4-6" });
25
- const b = compatibilityKey({ system: "s", model: "claude-opus-4-8" });
26
- expect(a).not.toBe(b);
27
- });
28
-
29
- it("text 모드 vs structured 모드 다른 키", () => {
30
- const text = compatibilityKey({ system: "s", model: "m" });
31
- const json = compatibilityKey({ system: "s", model: "m", outputSchema: '{"type":"object"}' });
32
- expect(text).not.toBe(json);
33
- });
34
-
35
- it("다른 schema 내용 → 다른 키 (codex P1-2: boolean 으로는 못 잡던 오염)", () => {
36
- const schemaA = compatibilityKey({
37
- system: "s",
38
- model: "m",
39
- outputSchema: '{"type":"object","properties":{"a":{"type":"string"}}}',
40
- });
41
- const schemaB = compatibilityKey({
42
- system: "s",
43
- model: "m",
44
- outputSchema: '{"type":"object","properties":{"b":{"type":"integer"}}}',
45
- });
46
- expect(schemaA).not.toBe(schemaB);
47
- });
48
-
49
- it("같은 schema 내용 → 같은 키", () => {
50
- const s = '{"type":"object"}';
51
- expect(compatibilityKey({ system: "s", model: "m", outputSchema: s })).toBe(
52
- compatibilityKey({ system: "s", model: "m", outputSchema: s }),
53
- );
54
- });
55
-
56
- it("system 다르면 다른 키", () => {
57
- const a = compatibilityKey({ system: "a", model: "m" });
58
- const b = compatibilityKey({ system: "b", model: "m" });
59
- expect(a).not.toBe(b);
60
- });
61
- });
62
-
63
19
  describe("makeAnthropicWorkerId (coord 매핑 P0-2)", () => {
64
20
  it("tokenId 기반 안정 합성", () => {
65
21
  expect(makeAnthropicWorkerId(7)).toBe(7);
@@ -164,11 +120,41 @@ describe("withSessionLock (per-session 락 P0-3)", () => {
164
120
  });
165
121
 
166
122
  describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
167
- it(" 호출: --session-id, resume 아님", () => {
168
- const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1", isResume: false });
123
+ it("applyOneMillionSuffix: 필요한 모델에만 [1m] suffix 를 단일 부착", () => {
124
+ expect(applyOneMillionSuffix("claude-sonnet-4-6", true)).toBe("claude-sonnet-4-6[1m]");
125
+ expect(applyOneMillionSuffix("claude-sonnet-4-6[1m]", true)).toBe("claude-sonnet-4-6[1m]");
126
+ expect(applyOneMillionSuffix("claude-opus-4-8", false)).toBe("claude-opus-4-8");
127
+ });
128
+
129
+ it("oneMillionEnv: 지원 모델은 DISABLE_1M 을 제거하고 미지원은 유지", () => {
130
+ expect(oneMillionEnv(true)).toEqual({});
131
+ expect(oneMillionEnv(false)).toEqual({ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" });
132
+ });
133
+
134
+ // SON-495: structured output retry 를 1 로 고정한다.
135
+ it("structuredOutputRetriesEnv: 미설정/비정상 값은 1 로 기본 고정", () => {
136
+ expect(structuredOutputRetriesEnv(undefined)).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
137
+ expect(structuredOutputRetriesEnv("")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
138
+ expect(structuredOutputRetriesEnv("abc")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
139
+ });
140
+
141
+ it("structuredOutputRetriesEnv: 1 미만(0, 음수)은 1 로 클램프", () => {
142
+ expect(structuredOutputRetriesEnv("0")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
143
+ expect(structuredOutputRetriesEnv("-3")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
144
+ });
145
+
146
+ it("structuredOutputRetriesEnv: 1 이상 값은 그대로 유지", () => {
147
+ expect(structuredOutputRetriesEnv("1")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
148
+ expect(structuredOutputRetriesEnv("5")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "5" });
149
+ });
150
+
151
+ it("cold-only: 항상 --session-id 를 사용하고 continuation flag 는 쓰지 않는다", () => {
152
+ const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1" });
153
+ const continuationFlag = "--resume";
169
154
  expect(args).toContain("--session-id");
170
155
  expect(args).toContain("uuid-1");
171
- expect(args).not.toContain("--resume");
156
+ expect(args).not.toContain(continuationFlag);
157
+ expect(args).not.toContain("--include-partial-messages");
172
158
  // 멀티턴 필수: --no-session-persistence 없어야 함
173
159
  expect(args).not.toContain("--no-session-persistence");
174
160
  // 입력 포맷
@@ -176,35 +162,79 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
176
162
  expect(args[args.indexOf("--input-format") + 1]).toBe("stream-json");
177
163
  });
178
164
 
179
- it("후속 호출: --resume <id>, session-id 아님", () => {
180
- const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1", isResume: true });
181
- expect(args).toContain("--resume");
182
- expect(args[args.indexOf("--resume") + 1]).toBe("uuid-1");
183
- expect(args).not.toContain("--session-id");
165
+ it("stream 호출: partial message delta 를 포함한다", () => {
166
+ const args = buildClaudeArgs({
167
+ model: "m",
168
+ sessionId: "uuid-1",
169
+ includePartialMessages: true,
170
+ });
171
+ expect(args).toContain("--include-partial-messages");
172
+ });
173
+
174
+ it("runClaudeSession: 이미 abort 된 signal 은 spawn 전에 실패한다", async () => {
175
+ const controller = new AbortController();
176
+ controller.abort();
177
+
178
+ await expect(
179
+ runClaudeSession(
180
+ {
181
+ tokenId: 999,
182
+ token: "sk-ant-oat01-test",
183
+ model: "haiku",
184
+ timeoutMs: 1_000,
185
+ input: [{ type: "text", text: "hi", text_elements: [] }],
186
+ abortSignal: controller.signal,
187
+ },
188
+ () => {},
189
+ ),
190
+ ).rejects.toThrow("aborted");
184
191
  });
185
192
 
186
193
  it("structured(jsonSchema 있음): --allowed-tools StructuredOutput + --json-schema (P2-7)", () => {
187
194
  const args = buildClaudeArgs({
188
195
  model: "m",
189
196
  sessionId: "u",
190
- isResume: false,
191
197
  jsonSchema: '{"type":"object"}',
192
198
  });
193
199
  expect(args).toContain("--allowed-tools");
194
200
  expect(args).toContain("StructuredOutput");
195
201
  expect(args).toContain("--json-schema");
202
+ expect(args).not.toContain("--max-turns");
203
+ });
204
+
205
+ it("1M suffix 는 CLI --model 인자에만 반영된다", () => {
206
+ const args = buildClaudeArgs({
207
+ model: "claude-sonnet-4-6",
208
+ sessionId: "u",
209
+ needsOneMillionSuffix: true,
210
+ });
211
+ expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-4-6[1m]");
212
+ });
213
+
214
+ it("1M 기본 지원 모델은 CLI --model 에 suffix 를 붙이지 않는다", () => {
215
+ const args = buildClaudeArgs({
216
+ model: "claude-opus-4-8",
217
+ sessionId: "u",
218
+ needsOneMillionSuffix: false,
219
+ });
220
+ expect(args[args.indexOf("--model") + 1]).toBe("claude-opus-4-8");
196
221
  });
197
222
 
198
223
  it("non-structured(jsonSchema 없음): --allowed-tools 미부여 (P2-7)", () => {
199
- const args = buildClaudeArgs({ model: "m", sessionId: "u", isResume: false });
224
+ const args = buildClaudeArgs({ model: "m", sessionId: "u" });
200
225
  expect(args).not.toContain("--allowed-tools");
201
226
  expect(args).not.toContain("--json-schema");
227
+ expect(args).not.toContain("--max-turns");
202
228
  // 단 --tools "" 로 전체 차단은 유지
203
229
  expect(args).toContain("--tools");
204
230
  });
205
231
 
206
232
  it("inline [System] 금지: --system-prompt 정식 채널 사용 (R7)", () => {
207
- const args = buildClaudeArgs({ model: "m", system: "you are X", sessionId: "u", isResume: false });
233
+ const args = buildClaudeArgs({
234
+ model: "m",
235
+ system: "you are X",
236
+ sessionId: "u",
237
+ });
208
238
  expect(args).toContain("--system-prompt");
209
239
  expect(args[args.indexOf("--system-prompt") + 1]).toBe("you are X");
210
240
  });
@@ -214,7 +244,6 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
214
244
  it("각 라인에 session_id/uuid/parent_tool_use_id:null 부착 + message 보존", () => {
215
245
  const lines = buildStreamJsonInput({
216
246
  input: [{ type: "text", text: "hi", text_elements: [] }],
217
- isResume: false,
218
247
  });
219
248
  const out = decorateAndSerialize(lines, "sess-xyz");
220
249
  const parsed = out
@@ -238,7 +267,6 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
238
267
  { type: "message", role: "assistant", content: [{ type: "output_text", text: "prev" }] },
239
268
  ],
240
269
  input: [{ type: "text", text: "now", text_elements: [] }],
241
- isResume: false,
242
270
  });
243
271
  const out = decorateAndSerialize(lines, "s");
244
272
  expect(out.endsWith("\n")).toBe(true);
@@ -252,7 +280,6 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
252
280
  { type: "message", role: "assistant", content: [{ type: "output_text", text: "a" }] },
253
281
  ],
254
282
  input: [{ type: "text", text: "b", text_elements: [] }],
255
- isResume: false,
256
283
  });
257
284
  const parsed = decorateAndSerialize(lines, "s")
258
285
  .trim()