@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.
Files changed (35) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +2 -6
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +74 -43
  3. package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
  4. package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
  5. package/bundle/dist/application/request-log/request-log.model.js +54 -8
  6. package/bundle/dist/sonamu.config.js +11 -2
  7. package/bundle/dist/testing/global.js +5 -2
  8. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
  9. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
  10. package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
  11. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
  12. package/bundle/dist/utils/providers/common/model-cost.js +35 -18
  13. package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
  14. package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
  15. package/bundle/src/application/qgrid/conv-routing.ts +3 -2
  16. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +110 -0
  17. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +112 -64
  18. package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
  19. package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
  20. package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
  21. package/bundle/src/application/request-log/request-log.model.ts +98 -13
  22. package/bundle/src/sonamu.config.ts +13 -2
  23. package/bundle/src/testing/global.ts +16 -2
  24. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
  25. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
  26. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
  27. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
  28. package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
  29. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
  30. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
  31. package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
  32. package/bundle/src/utils/providers/common/model-cost.ts +107 -19
  33. package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
  34. package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
  35. package/package.json +1 -1
@@ -0,0 +1,365 @@
1
+ /**
2
+ * AnthropicDispatcher — Claude(Anthropic) provider 의 ProviderDispatcher 구현.
3
+ *
4
+ * OpenAIDispatcher 패턴을 미러링하되, worker pool 대신 요청별 fresh spawn(U4 runClaudeSession)을
5
+ * 쓴다. 토큰은 자체 풀(Map)로 관리하고 least-used round-robin 으로 고른다.
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)가 클라에 회송할 좌표를 만든다.
21
+ */
22
+
23
+ import { getLogger } from "@logtape/logtape";
24
+
25
+ import { TokenModel } from "../../../application/token/token.model";
26
+ import { type AnthropicCredentials } from "../../../application/token/token.types";
27
+ import { getExpiresAt, getRefreshToken } from "../common/credentials";
28
+ import {
29
+ type GenerateRequest,
30
+ type GenerateResult,
31
+ type GenerateStreamCallbacks,
32
+ type ProviderDispatcher,
33
+ } from "../common/provider-dispatcher";
34
+ import { canonicalAnthropicModel } from "./anthropic-constants";
35
+ import { compatibilityKey, makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
36
+
37
+ const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
38
+
39
+ const DEFAULT_TIMEOUT_MS = 120_000;
40
+ // access token 만료 임박 임계 — 기존 standalone 경로(qgrid.dispatcher.ts)와 동일(60s).
41
+ const REFRESH_SAFETY_MS = 60_000;
42
+
43
+ interface PooledToken {
44
+ id: number;
45
+ name: string;
46
+ credentials: AnthropicCredentials;
47
+ }
48
+
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
+ export class AnthropicDispatcher implements ProviderDispatcher {
89
+ // tokenId → 풀 항목. start()/token 이벤트로 채운다.
90
+ private tokenPool = new Map<number, PooledToken>();
91
+ // tokenId 별 사용 카운터(least-used RR). name 이 아니라 id 기준 — 이름 충돌 안전.
92
+ private requestCounts = new Map<number, number>();
93
+ 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
+
100
+ async start(): Promise<void> {
101
+ // OpenAIDispatcher.start() 와 동일하게 DB 에서 기존 anthropic 토큰을 self-bootstrap 한다.
102
+ // (서버 재시작 시 NOTIFY 가 안 오므로 부트스트랩이 없으면 풀이 빈 채로 남는다.)
103
+ // 이후 변경분은 token-subscriber 가 onTokenAdded/Updated/Removed 로 전달한다.
104
+ // findActiveByProvider 가 active=true + provider 필터를 DB 에서 처리 → inactive 는 애초에 안 온다.
105
+ const tokens = await TokenModel.findActiveByProvider("A", "anthropic");
106
+ for (const t of tokens) {
107
+ this.tokenPool.set(t.id, {
108
+ id: t.id,
109
+ name: t.name,
110
+ credentials: t.credentials as AnthropicCredentials,
111
+ });
112
+ }
113
+ logger.info(`anthropic dispatcher started (${this.tokenPool.size} tokens)`);
114
+ }
115
+
116
+ async stop(): Promise<void> {
117
+ this.tokenPool.clear();
118
+ this.sessionCompat.clear();
119
+ // lifecycle 정합: RR 카운터/인덱스도 비워 stop→start 재등록 시 이전 카운트가 안 남게 한다.
120
+ this.requestCounts.clear();
121
+ this.rrIndex = 0;
122
+ }
123
+
124
+ // 현재 풀의 토큰 수(startup 로그/health 용).
125
+ get tokenCount(): number {
126
+ return this.tokenPool.size;
127
+ }
128
+
129
+ // DB active anthropic 토큰 목록으로 풀을 재동기화한다(periodic reconcile/재연결용).
130
+ // LISTEN/NOTIFY 가 끊긴 동안 유실된 추가/삭제/비활성화를 DB 기준으로 다시 맞춘다.
131
+ // 이벤트 핸들러를 재사용해 identity-change / sessionCompat 정리가 일관되게 적용되도록 한다:
132
+ // - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved (compat 정리 포함)
133
+ // - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated(identity 변경 감지)
134
+ replaceTokens(
135
+ rows: Array<{ id: number; name: string; credentials: AnthropicCredentials }>,
136
+ ): void {
137
+ const next = new Set(rows.map((r) => r.id));
138
+ // onTokenRemoved 가 tokenPool 을 delete 하므로 순회 중 수정을 피하려 키를 먼저 스냅샷한다.
139
+ const currentIds = Array.from(this.tokenPool.keys());
140
+ for (const id of currentIds) {
141
+ if (!next.has(id)) this.onTokenRemoved(id);
142
+ }
143
+ for (const r of rows) {
144
+ if (this.tokenPool.has(r.id)) this.onTokenUpdated(r.id, r.name, r.credentials);
145
+ else this.onTokenAdded(r.id, r.name, r.credentials);
146
+ }
147
+ }
148
+
149
+ // ── token events (token-subscriber 가 호출) ──────────────────────
150
+
151
+ onTokenAdded(id: number, name: string, credentials: AnthropicCredentials): void {
152
+ this.tokenPool.set(id, { id, name, credentials });
153
+ }
154
+ 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
+ this.tokenPool.set(id, { id, name, credentials });
165
+ }
166
+ onTokenRemoved(id: number): void {
167
+ this.tokenPool.delete(id);
168
+ this.requestCounts.delete(id);
169
+ // 이 토큰으로 만든 세션의 compat 엔트리도 정리.
170
+ this.clearSessionCompatFor(id);
171
+ }
172
+
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
+ private countOf(id: number): number {
183
+ return this.requestCounts.get(id) ?? 0;
184
+ }
185
+
186
+ // cold 경로: least-used RR 로 토큰을 고른다(동점이면 rrIndex 로 회전).
187
+ private selectToken(): PooledToken | null {
188
+ const rows = [...this.tokenPool.values()];
189
+ if (rows.length === 0) return null;
190
+ const minCount = Math.min(...rows.map((r) => this.countOf(r.id)));
191
+ const idle = rows.filter((r) => this.countOf(r.id) === minCount);
192
+ const picked = idle[this.rrIndex % idle.length]!;
193
+ this.rrIndex++;
194
+ return this.charge(picked);
195
+ }
196
+
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
+ // 선택된 토큰의 사용 카운트를 await 전에 선반영(동시 요청이 다른 토큰을 고르도록).
206
+ private charge(token: PooledToken): PooledToken {
207
+ this.requestCounts.set(token.id, this.countOf(token.id) + 1);
208
+ return token;
209
+ }
210
+
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
+ async generate(req: GenerateRequest): Promise<GenerateResult> {
222
+ return this.run(req, () => {});
223
+ }
224
+
225
+ async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
226
+ try {
227
+ const result = await this.run(req, cb.onDelta);
228
+ cb.onThreadId?.(result.threadCoord.threadId);
229
+ cb.onComplete(result);
230
+ } catch (e) {
231
+ cb.onError(e as Error);
232
+ }
233
+ }
234
+
235
+ // 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 전부
240
+ // canonical(prefix 없는 cost 키) 을 쓰게 통일. 미지정이면 ANTHROPIC_DEFAULT_MODEL — qgrid.dispatcher
241
+ // 의 "sonnet" 별칭 우회 차단. 정규화 규칙은 fallback 경로와 공유(canonicalAnthropicModel).
242
+ const model = canonicalAnthropicModel(req.model);
243
+ const jsonSchema =
244
+ 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
+
261
+ const isResume = resumeToken !== null;
262
+ const resumeSessionId = isResume ? candidate : undefined;
263
+
264
+ // resume 면 소유 토큰 고정, 아니면 cold RR 선택.
265
+ const token = resumeToken ?? this.selectToken();
266
+ if (!token) throw new Error("No anthropic tokens available");
267
+
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
+ const exec = async (): Promise<GenerateResult> => {
278
+ // 만료 임박 토큰 preemptive refresh(기존 standalone 경로와 동일). 실패해도 진행.
279
+ let accessToken = token.credentials.accessToken;
280
+ const expiresAt = getExpiresAt(token.credentials);
281
+ if (
282
+ expiresAt &&
283
+ expiresAt - Date.now() < REFRESH_SAFETY_MS &&
284
+ getRefreshToken(token.credentials)
285
+ ) {
286
+ try {
287
+ const { QgridFrame } = await import("../../../application/qgrid/qgrid.frame");
288
+ // provider 를 반드시 채워야 refreshToken 내부 TokenModel.save 가 성공한다.
289
+ // refreshToken(TokenSubsetA) 는 id/provider/credentials/name 만 사용하므로 그 4개만 넘긴다.
290
+ accessToken = await QgridFrame.refreshToken({
291
+ id: token.id,
292
+ provider: ANTHROPIC_PROVIDER,
293
+ name: token.name,
294
+ credentials: token.credentials,
295
+ } as Parameters<typeof QgridFrame.refreshToken>[0]);
296
+ } catch (e) {
297
+ logger.warn(`refresh failed for ${token.name}: ${(e as Error).message}`);
298
+ }
299
+ }
300
+
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
+ }
330
+
331
+ if (session.quotaExhausted) throw new Error(`quota exhausted (${token.name})`);
332
+ 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}`);
336
+ }
337
+
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
+ return {
348
+ text: session.text,
349
+ tokenName: token.name,
350
+ usage: session.usage,
351
+ durationMs: session.durationMs,
352
+ costUsd: session.costUsd,
353
+ model,
354
+ // systemHash 는 상위 issueConvContext 가 채운다(여기선 비운다).
355
+ threadCoord: {
356
+ workerId: makeAnthropicWorkerId(token.id),
357
+ threadId: session.sessionId,
358
+ epoch: 0,
359
+ },
360
+ };
361
+ };
362
+
363
+ return exec();
364
+ }
365
+ }
@@ -0,0 +1,263 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { ANTHROPIC_CONFIG_DIR_BASE, anthropicConfigDir } from "./anthropic-constants";
6
+ import {
7
+ buildClaudeArgs,
8
+ compatibilityKey,
9
+ decorateAndSerialize,
10
+ ensureConfigDir,
11
+ makeAnthropicWorkerId,
12
+ withSessionLock,
13
+ } from "./claude-session";
14
+ import { buildStreamJsonInput } from "./stream-json-adapter";
15
+
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
+ describe("makeAnthropicWorkerId (coord 매핑 P0-2)", () => {
64
+ it("tokenId 기반 안정 합성", () => {
65
+ expect(makeAnthropicWorkerId(7)).toBe(7);
66
+ expect(makeAnthropicWorkerId(42)).toBe(42);
67
+ });
68
+ });
69
+
70
+ describe("anthropicConfigDir (R10 토큰 격리 계약 — U6 구조 검증)", () => {
71
+ it("tokenId 별로 다른 config dir", () => {
72
+ expect(anthropicConfigDir(1)).not.toBe(anthropicConfigDir(2));
73
+ });
74
+
75
+ it("격리 단위는 tokenId — session-id 와 무관하게 토큰별로 config dir 이 갈린다 (transcript 안 섞임)", () => {
76
+ // CLAUDE_CONFIG_DIR 은 session-id 가 아니라 tokenId 로만 결정된다(이 함수는 session-id 를 입력으로
77
+ // 받지도 않는다). 따라서 두 토큰이 우연히 같은 claude session-id 를 쓰더라도 transcript 저장
78
+ // 위치(config dir)가 토큰별로 분리되어 오염되지 않는다 — tokenId 가 격리 단위.
79
+ const dirForToken1 = anthropicConfigDir(1);
80
+ const dirForToken2 = anthropicConfigDir(2);
81
+ expect(dirForToken1).not.toBe(dirForToken2);
82
+ });
83
+
84
+ it("base 하위 경로 + tokenId 안정", () => {
85
+ expect(anthropicConfigDir(7)).toBe(`${ANTHROPIC_CONFIG_DIR_BASE}/7`);
86
+ expect(anthropicConfigDir(7)).toBe(anthropicConfigDir(7)); // 결정적
87
+ });
88
+ });
89
+
90
+ describe("ensureConfigDir (R10 격리 seed self-healing)", () => {
91
+ it("이미 ensure 한 tokenId 라도 seed 파일을 매 호출 복구한다", () => {
92
+ const tokenId = -990001;
93
+ const dir = anthropicConfigDir(tokenId);
94
+ rmSync(dir, { recursive: true, force: true });
95
+
96
+ try {
97
+ expect(ensureConfigDir(tokenId)).toBe(dir);
98
+ expect(readFileSync(`${dir}/.claude.json`, "utf8")).toBe("{}");
99
+ expect(readFileSync(`${dir}/settings.json`, "utf8")).toBe("{}");
100
+
101
+ writeFileSync(`${dir}/.claude.json`, '{"mutated":true}');
102
+ writeFileSync(`${dir}/settings.json`, '{"hooks":{"UserPromptSubmit":[{}]}}');
103
+ ensureConfigDir(tokenId);
104
+ expect(readFileSync(`${dir}/.claude.json`, "utf8")).toBe("{}");
105
+ expect(readFileSync(`${dir}/settings.json`, "utf8")).toBe("{}");
106
+
107
+ rmSync(dir, { recursive: true, force: true });
108
+ ensureConfigDir(tokenId);
109
+ expect(existsSync(`${dir}/.claude.json`)).toBe(true);
110
+ expect(existsSync(`${dir}/settings.json`)).toBe(true);
111
+ expect(readFileSync(`${dir}/settings.json`, "utf8")).toBe("{}");
112
+ } finally {
113
+ rmSync(dir, { recursive: true, force: true });
114
+ }
115
+ });
116
+ });
117
+
118
+ describe("withSessionLock (per-session 락 P0-3)", () => {
119
+ it("같은 session-id 의 동시 호출은 직렬화된다", async () => {
120
+ const order: Array<string> = [];
121
+ const slow = async (tag: string, ms: number) => {
122
+ order.push(`${tag}:start`);
123
+ await new Promise((r) => setTimeout(r, ms));
124
+ order.push(`${tag}:end`);
125
+ return tag;
126
+ };
127
+ // 같은 세션으로 두 turn 을 동시에 던짐. 직렬화되면 A 가 끝난 뒤 B 가 시작.
128
+ const p1 = withSessionLock("sess-1", () => slow("A", 30));
129
+ const p2 = withSessionLock("sess-1", () => slow("B", 5));
130
+ await Promise.all([p1, p2]);
131
+ // A 가 먼저 완료된 뒤 B 가 시작 (겹치지 않음)
132
+ expect(order).toEqual(["A:start", "A:end", "B:start", "B:end"]);
133
+ });
134
+
135
+ it("다른 session-id 는 병렬 허용 (겹쳐도 됨)", async () => {
136
+ const events: Array<string> = [];
137
+ const p1 = withSessionLock("a", async () => {
138
+ events.push("a:start");
139
+ await new Promise((r) => setTimeout(r, 20));
140
+ events.push("a:end");
141
+ });
142
+ const p2 = withSessionLock("b", async () => {
143
+ events.push("b:start");
144
+ await new Promise((r) => setTimeout(r, 5));
145
+ events.push("b:end");
146
+ });
147
+ await Promise.all([p1, p2]);
148
+ // 다른 세션이라 b 가 a 보다 먼저 끝남 (병렬)
149
+ expect(events.indexOf("b:end")).toBeLessThan(events.indexOf("a:end"));
150
+ });
151
+
152
+ it("앞 turn 이 throw 해도 다음 turn 은 실행된다", async () => {
153
+ const ran: Array<string> = [];
154
+ const p1 = withSessionLock("s", async () => {
155
+ ran.push("1");
156
+ throw new Error("boom");
157
+ }).catch(() => {});
158
+ const p2 = withSessionLock("s", async () => {
159
+ ran.push("2");
160
+ });
161
+ await Promise.all([p1, p2]);
162
+ expect(ran).toEqual(["1", "2"]);
163
+ });
164
+ });
165
+
166
+ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
167
+ it("첫 호출: --session-id, resume 아님", () => {
168
+ const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1", isResume: false });
169
+ expect(args).toContain("--session-id");
170
+ expect(args).toContain("uuid-1");
171
+ expect(args).not.toContain("--resume");
172
+ // 멀티턴 필수: --no-session-persistence 없어야 함
173
+ expect(args).not.toContain("--no-session-persistence");
174
+ // 입력 포맷
175
+ expect(args).toContain("--input-format");
176
+ expect(args[args.indexOf("--input-format") + 1]).toBe("stream-json");
177
+ });
178
+
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");
184
+ });
185
+
186
+ it("structured(jsonSchema 있음): --allowed-tools StructuredOutput + --json-schema (P2-7)", () => {
187
+ const args = buildClaudeArgs({
188
+ model: "m",
189
+ sessionId: "u",
190
+ isResume: false,
191
+ jsonSchema: '{"type":"object"}',
192
+ });
193
+ expect(args).toContain("--allowed-tools");
194
+ expect(args).toContain("StructuredOutput");
195
+ expect(args).toContain("--json-schema");
196
+ });
197
+
198
+ it("non-structured(jsonSchema 없음): --allowed-tools 미부여 (P2-7)", () => {
199
+ const args = buildClaudeArgs({ model: "m", sessionId: "u", isResume: false });
200
+ expect(args).not.toContain("--allowed-tools");
201
+ expect(args).not.toContain("--json-schema");
202
+ // 단 --tools "" 로 전체 차단은 유지
203
+ expect(args).toContain("--tools");
204
+ });
205
+
206
+ it("inline [System] 금지: --system-prompt 정식 채널 사용 (R7)", () => {
207
+ const args = buildClaudeArgs({ model: "m", system: "you are X", sessionId: "u", isResume: false });
208
+ expect(args).toContain("--system-prompt");
209
+ expect(args[args.indexOf("--system-prompt") + 1]).toBe("you are X");
210
+ });
211
+ });
212
+
213
+ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
214
+ it("각 라인에 session_id/uuid/parent_tool_use_id:null 부착 + message 보존", () => {
215
+ const lines = buildStreamJsonInput({
216
+ input: [{ type: "text", text: "hi", text_elements: [] }],
217
+ isResume: false,
218
+ });
219
+ const out = decorateAndSerialize(lines, "sess-xyz");
220
+ const parsed = out
221
+ .trim()
222
+ .split("\n")
223
+ .map((l) => JSON.parse(l));
224
+ expect(parsed).toHaveLength(1);
225
+ const line = parsed[0]!;
226
+ // 최종 직렬화 shape (codex U2 리뷰가 U4에 요구한 것)
227
+ expect(line.type).toBe("user");
228
+ expect(line.session_id).toBe("sess-xyz");
229
+ expect(typeof line.uuid).toBe("string");
230
+ expect(line.uuid.length).toBeGreaterThan(0);
231
+ expect(line.parent_tool_use_id).toBeNull();
232
+ expect(line.message).toEqual({ role: "user", content: [{ type: "text", text: "hi" }] });
233
+ });
234
+
235
+ it("JSONL: 줄당 하나 + 마지막 개행", () => {
236
+ const lines = buildStreamJsonInput({
237
+ coldHistory: [
238
+ { type: "message", role: "assistant", content: [{ type: "output_text", text: "prev" }] },
239
+ ],
240
+ input: [{ type: "text", text: "now", text_elements: [] }],
241
+ isResume: false,
242
+ });
243
+ const out = decorateAndSerialize(lines, "s");
244
+ expect(out.endsWith("\n")).toBe(true);
245
+ // assistant context 1줄 + user 1줄
246
+ expect(out.trim().split("\n")).toHaveLength(2);
247
+ });
248
+
249
+ it("각 라인 uuid 는 서로 다름", () => {
250
+ const lines = buildStreamJsonInput({
251
+ coldHistory: [
252
+ { type: "message", role: "assistant", content: [{ type: "output_text", text: "a" }] },
253
+ ],
254
+ input: [{ type: "text", text: "b", text_elements: [] }],
255
+ isResume: false,
256
+ });
257
+ const parsed = decorateAndSerialize(lines, "s")
258
+ .trim()
259
+ .split("\n")
260
+ .map((l) => JSON.parse(l));
261
+ expect(parsed[0]!.uuid).not.toBe(parsed[1]!.uuid);
262
+ });
263
+ });