@cartanova/qgrid-cli 2.1.0 → 2.2.0

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 (37) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +68 -0
  2. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +16 -15
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +17 -5
  5. package/bundle/dist/application/qgrid/qgrid.types.js +12 -3
  6. package/bundle/dist/application/qgrid/tool-emulation.js +11 -6
  7. package/bundle/dist/utils/providers/openai/codex-worker.js +140 -53
  8. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +75 -21
  9. package/bundle/src/application/qgrid/conv-routing.ts +97 -0
  10. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +2 -1
  11. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
  12. package/bundle/src/application/qgrid/qgrid.frame.ts +20 -5
  13. package/bundle/src/application/qgrid/qgrid.types.ts +14 -1
  14. package/bundle/src/application/qgrid/tool-emulation.ts +20 -5
  15. package/bundle/src/application/sonamu.generated.http +14 -2
  16. package/bundle/src/utils/providers/common/provider-dispatcher.ts +29 -2
  17. package/bundle/src/utils/providers/openai/codex-worker.test.ts +163 -0
  18. package/bundle/src/utils/providers/openai/codex-worker.ts +199 -39
  19. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +59 -0
  20. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +116 -27
  21. package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +1 -0
  22. package/bundle/web-dist/client/assets/{index-ZV-tHpAg.js → index-DeThumA3.js} +2 -2
  23. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +1 -0
  24. package/bundle/web-dist/client/assets/{routes-BMRwPj6F.js → routes-Cfoe9J0I.js} +1 -1
  25. package/bundle/web-dist/client/assets/show-C6OofoAL.js +30 -0
  26. package/bundle/web-dist/client/assets/{tokens-Cg1f0Ga2.js → tokens-BAZq8sTo.js} +1 -1
  27. package/bundle/web-dist/client/index.html +1 -1
  28. package/bundle/web-dist/server/assets/cost-DzjfVtUr.js +24 -0
  29. package/bundle/web-dist/server/assets/{logs-Xt934T5f.js → logs-CHM0fhfO.js} +2 -7
  30. package/bundle/web-dist/server/assets/{routes-B7QYbNIv.js → routes-C9xFMJ_r.js} +1 -1
  31. package/bundle/web-dist/server/assets/{show-Dxz6UJnD.js → show-BZWqH09U.js} +2 -4
  32. package/bundle/web-dist/server/entry-server.generated.js +3 -3
  33. package/package.json +1 -1
  34. package/bundle/web-dist/client/assets/cost-DE_8cLD-.js +0 -1
  35. package/bundle/web-dist/client/assets/logs-HJYkbqom.js +0 -1
  36. package/bundle/web-dist/client/assets/show-DI6iWDzV.js +0 -30
  37. package/bundle/web-dist/server/assets/cost-DdHTzQDR.js +0 -14
@@ -54,6 +54,9 @@ export interface StreamCallbacks {
54
54
  onTurnId?: (turnId: string) => void;
55
55
  }
56
56
 
57
+ type RefreshableWorkerCredentials = Pick<WorkerConfig, "accessToken" | "accountId" | "planType">;
58
+ type ActiveTurnAbort = (error: Error) => void;
59
+
57
60
  // thread/start 공통 옵션
58
61
  const THREAD_DEFAULTS = {
59
62
  sandbox: "read-only",
@@ -63,10 +66,8 @@ const THREAD_DEFAULTS = {
63
66
  },
64
67
  } satisfies Partial<ThreadStartParams>;
65
68
 
66
- const BASE_INSTRUCTIONS = {
67
- text: "You are a helpful assistant. Do not use any tools such as shell, file operations, or web search. Respond with text only.",
68
- withSchema: "You are a helpful assistant. Respond using the provided output schema.",
69
- } as const;
69
+ const BASE_INSTRUCTIONS =
70
+ "You are a helpful assistant. Do not use any tools such as shell, file operations, or web search. Respond with text only.";
70
71
 
71
72
  // codex가 매 요청에 자동 주입하는 내장 tool(shell/web_search/spawn_agent 등 14개)과
72
73
  // instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
@@ -103,8 +104,19 @@ export class CodexAppServerWorker {
103
104
  private ready = false;
104
105
  private destroyed = false;
105
106
  private busy = false;
107
+ private activeTurnAbort?: ActiveTurnAbort;
106
108
  active = true;
107
109
  onReady?: () => void;
110
+ // restart 시 rpc 객체가 새로 생성되므로, 핸들러를 보관했다가 매 spawn마다 재바인딩한다.
111
+ serverRequestHandler?: (method: string, params: unknown) => Promise<unknown>;
112
+
113
+ // thread 재사용(prompt cache): worker 가 생성한 thread 들의 메타.
114
+ // ephemeral thread 는 이 프로세스 메모리에만 존재하므로 restart 시 전부 무효.
115
+ private threadMeta = new Map<string, { lastUsedAt: number }>();
116
+ // spawn 카운터. restart 마다 증가 → conv 핸들의 epoch 와 대조해 stale thread 감지.
117
+ private epochCounter = 0;
118
+ private static readonly THREAD_IDLE_TTL_MS = 10 * 60_000;
119
+ private static readonly MAX_THREADS_PER_WORKER = 16;
108
120
 
109
121
  constructor(private config: WorkerConfig) {
110
122
  const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
@@ -114,6 +126,9 @@ export class CodexAppServerWorker {
114
126
  // ── Lifecycle ───────────────────────────────────────────────────
115
127
 
116
128
  private async spawnAndInit(): Promise<void> {
129
+ // 새 codex 프로세스 → 기존 ephemeral thread 전부 무효. epoch 증가로 stale 감지.
130
+ this.epochCounter++;
131
+ this.threadMeta.clear();
117
132
  const cwd = `${this.codexHome}/cwd`;
118
133
  mkdirSync(cwd, { recursive: true });
119
134
  // codex 내장 tool/web_search/instruction 블록 비활성화
@@ -133,6 +148,9 @@ export class CodexAppServerWorker {
133
148
 
134
149
  this.proc.on("exit", (code) => {
135
150
  logger.info(`worker ${this.config.tokenName} exited (code=${code})`);
151
+ this.failActiveTurn(
152
+ new Error(`codex worker exited while turn was running (code=${code ?? "unknown"})`),
153
+ );
136
154
  this.ready = false;
137
155
  this.rpc = null;
138
156
  this.proc = null;
@@ -140,6 +158,8 @@ export class CodexAppServerWorker {
140
158
  });
141
159
 
142
160
  this.rpc = new CodexRpcClient(this.proc);
161
+ // restart 후에도 refresh server-request 핸들러를 유지 (없으면 codex 토큰 회전 실패 → session expired)
162
+ this.bindServerRequestHandler();
143
163
 
144
164
  await this.rpc.request("initialize", {
145
165
  clientInfo: { name: "qgrid", version: "1.0.0" },
@@ -212,6 +232,7 @@ export class CodexAppServerWorker {
212
232
  async kill(): Promise<void> {
213
233
  this.destroyed = true;
214
234
  this.ready = false;
235
+ this.failActiveTurn(new Error("codex worker stopped"));
215
236
  this.rpc?.destroy();
216
237
 
217
238
  if (this.proc) {
@@ -242,10 +263,31 @@ export class CodexAppServerWorker {
242
263
  get tokenId(): number {
243
264
  return this.config.tokenId;
244
265
  }
266
+ get workerIndex(): number {
267
+ return this.config.workerIndex ?? 0;
268
+ }
245
269
  get tokenName(): string {
246
270
  return this.config.tokenName;
247
271
  }
248
272
 
273
+ canReuseForToken(_tokenName: string, credentials: RefreshableWorkerCredentials): boolean {
274
+ return (
275
+ this.config.accountId === credentials.accountId &&
276
+ (this.config.planType ?? undefined) === (credentials.planType ?? undefined)
277
+ );
278
+ }
279
+
280
+ updateTokenState(tokenName: string, credentials: RefreshableWorkerCredentials): void {
281
+ this.config.tokenName = tokenName;
282
+ this.config.accessToken = credentials.accessToken;
283
+ this.config.accountId = credentials.accountId;
284
+ if (credentials.planType) {
285
+ this.config.planType = credentials.planType;
286
+ } else {
287
+ delete this.config.planType;
288
+ }
289
+ }
290
+
249
291
  // ── Rate limits ─────────────────────────────────────────────────
250
292
 
251
293
  async getRateLimits(): Promise<unknown> {
@@ -256,6 +298,14 @@ export class CodexAppServerWorker {
256
298
  // ── Server-request delegation ───────────────────────────────────
257
299
 
258
300
  setServerRequestHandler(handler: (method: string, params: unknown) => Promise<unknown>): void {
301
+ this.serverRequestHandler = handler;
302
+ this.bindServerRequestHandler();
303
+ }
304
+
305
+ // spawnAndInit에서 rpc 생성 직후 호출. restart로 rpc가 새로 만들어져도 핸들러가 유지된다.
306
+ bindServerRequestHandler(): void {
307
+ const handler = this.serverRequestHandler;
308
+ if (!handler) return;
259
309
  this.rpc?.onServerRequest("account/chatgptAuthTokens/refresh", (params) =>
260
310
  handler("account/chatgptAuthTokens/refresh", params),
261
311
  );
@@ -275,10 +325,12 @@ export class CodexAppServerWorker {
275
325
  return !this.busy;
276
326
  }
277
327
 
278
- async startThread(
279
- req: TurnRequest,
280
- ): Promise<{ threadId: string; turnId: string; model: string }> {
328
+ // 새 thread 생성 (thread/start + 필요 시 history inject). 첫 turn / 폴백 경로용.
329
+ // 후속 turn 은 createThread 없이 startTurnOnThread 만 호출해 conversation_id 를 고정한다.
330
+ // thread 기본 model 반환 (req.model 미지정 fallback).
331
+ async createThread(req: TurnRequest): Promise<{ threadId: string; model: string }> {
281
332
  if (!this.rpc || !this.ready) throw new Error("worker not ready");
333
+ this.sweepIdleThreads();
282
334
 
283
335
  const threadConfig: ThreadStartParams["config"] = {
284
336
  ...THREAD_DEFAULTS.config,
@@ -289,7 +341,7 @@ export class CodexAppServerWorker {
289
341
  {
290
342
  ephemeral: true,
291
343
  cwd: `${this.codexHome}/cwd`,
292
- baseInstructions: req.outputSchema ? BASE_INSTRUCTIONS.withSchema : BASE_INSTRUCTIONS.text,
344
+ baseInstructions: BASE_INSTRUCTIONS,
293
345
  developerInstructions: req.developerInstructions ?? "",
294
346
  sandbox: THREAD_DEFAULTS.sandbox,
295
347
  approvalPolicy: THREAD_DEFAULTS.approvalPolicy,
@@ -298,13 +350,20 @@ export class CodexAppServerWorker {
298
350
  );
299
351
 
300
352
  const threadId = thread.id;
301
- const model = req.model ?? threadModel;
302
353
  if (req.history?.length) {
303
354
  await this.rpc.request("thread/inject_items", {
304
355
  threadId,
305
356
  items: req.history,
306
357
  } satisfies ThreadInjectItemsParams);
307
358
  }
359
+ this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
360
+
361
+ return { threadId, model: req.model ?? threadModel };
362
+ }
363
+
364
+ // 기존 thread 에 turn 만 실행 (inject 없음). conversation_id 유지 → prompt cache 적중.
365
+ private async startTurnOnThread(threadId: string, req: TurnRequest): Promise<{ turnId: string }> {
366
+ if (!this.rpc || !this.ready) throw new Error("worker not ready");
308
367
 
309
368
  const { turn } = await this.rpc.request<TurnStartResponse>("turn/start", {
310
369
  threadId,
@@ -315,20 +374,79 @@ export class CodexAppServerWorker {
315
374
  ...(req.reasoningSummary ? { summary: req.reasoningSummary } : {}),
316
375
  ...(req.serviceTier ? { serviceTier: req.serviceTier } : {}),
317
376
  });
318
-
319
- return { threadId, turnId: turn.id, model };
377
+ const meta = this.threadMeta.get(threadId);
378
+ if (meta) meta.lastUsedAt = Date.now();
379
+ return { turnId: turn.id };
320
380
  }
321
381
 
322
- async executeTurn(req: TurnRequest): Promise<TurnResult> {
323
- const { threadId, model } = await this.startThread(req);
324
- return this.consumeTurnNotifications(threadId, model);
382
+ // existingThreadId 있으면 그 thread 재사용(후속 turn), 없으면 새 thread 생성(첫 turn).
383
+ // 호출 후 사용한 threadId 반환해 dispatcher conv 핸들을 발급한다.
384
+ async executeTurn(
385
+ req: TurnRequest,
386
+ existingThreadId?: string,
387
+ ): Promise<TurnResult & { threadId: string }> {
388
+ let threadId: string;
389
+ let model: string;
390
+ if (existingThreadId) {
391
+ threadId = existingThreadId;
392
+ model = req.model ?? "";
393
+ } else {
394
+ ({ threadId, model } = await this.createThread(req));
395
+ }
396
+ await this.startTurnOnThread(threadId, req);
397
+ const result = await this.consumeTurnNotifications(threadId, model);
398
+ return { ...result, threadId };
325
399
  }
326
400
 
327
- async executeTurnStream(req: TurnRequest, cb: StreamCallbacks): Promise<void> {
328
- const { threadId, turnId, model } = await this.startThread(req);
401
+ async executeTurnStream(
402
+ req: TurnRequest,
403
+ cb: StreamCallbacks,
404
+ existingThreadId?: string,
405
+ ): Promise<{ threadId: string }> {
406
+ let threadId: string;
407
+ let model: string;
408
+ if (existingThreadId) {
409
+ threadId = existingThreadId;
410
+ model = req.model ?? "";
411
+ } else {
412
+ ({ threadId, model } = await this.createThread(req));
413
+ }
414
+ const { turnId } = await this.startTurnOnThread(threadId, req);
329
415
  cb.onThreadId?.(threadId);
330
416
  cb.onTurnId?.(turnId);
331
- return this.consumeStreamNotifications(threadId, model, cb);
417
+ await this.consumeStreamNotifications(threadId, model, cb);
418
+ return { threadId };
419
+ }
420
+
421
+ // ── thread 재사용 지원 ───────────────────────────────────────────
422
+
423
+ get epoch(): number {
424
+ return this.epochCounter;
425
+ }
426
+
427
+ hasThread(threadId: string): boolean {
428
+ return this.threadMeta.has(threadId);
429
+ }
430
+
431
+ // idle TTL 초과 / 개수 상한 초과 thread 를 정리. createThread 직전 lazy 호출.
432
+ // codex ephemeral thread 는 명시적 close RPC 없이 맵에서 제거 → 더 이상 turn 안 보냄.
433
+ sweepIdleThreads(): void {
434
+ const now = Date.now();
435
+ for (const [id, meta] of this.threadMeta) {
436
+ if (now - meta.lastUsedAt > CodexAppServerWorker.THREAD_IDLE_TTL_MS) {
437
+ this.threadMeta.delete(id);
438
+ }
439
+ }
440
+ // createThread 직전 호출 → 새 thread 1 개가 곧 추가된다. 추가 후에도 MAX 를 넘지 않도록
441
+ // MAX-1 이하로 줄여 둔다(그래야 생성 직후 정확히 MAX). LRU: lastUsedAt 오름차순으로 제거.
442
+ const limit = CodexAppServerWorker.MAX_THREADS_PER_WORKER - 1;
443
+ if (this.threadMeta.size > limit) {
444
+ const sorted = [...this.threadMeta.entries()].toSorted(
445
+ (a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
446
+ );
447
+ const over = this.threadMeta.size - limit;
448
+ for (let i = 0; i < over; i++) this.threadMeta.delete(sorted[i]![0]);
449
+ }
332
450
  }
333
451
 
334
452
  async interruptTurn(threadId: string, turnId: string): Promise<void> {
@@ -338,11 +456,18 @@ export class CodexAppServerWorker {
338
456
  } catch {}
339
457
  }
340
458
 
459
+ private failActiveTurn(error: Error): void {
460
+ const abort = this.activeTurnAbort;
461
+ this.activeTurnAbort = undefined;
462
+ abort?.(error);
463
+ }
464
+
341
465
  private consumeTurnNotifications(threadId: string, model: string): Promise<TurnResult> {
342
466
  return new Promise<TurnResult>((resolve, reject) => {
467
+ let settled = false;
468
+ let abort: ActiveTurnAbort;
343
469
  const timeout = setTimeout(() => {
344
- cleanup();
345
- reject(new Error("turn timeout (600s)"));
470
+ finishWithError(new Error("turn timeout (600s)"));
346
471
  }, 600_000);
347
472
 
348
473
  let text = "";
@@ -357,6 +482,7 @@ export class CodexAppServerWorker {
357
482
 
358
483
  const cleanup = () => {
359
484
  clearTimeout(timeout);
485
+ if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
360
486
  for (const n of [
361
487
  "item/completed",
362
488
  "item/agentMessage/delta",
@@ -368,6 +494,23 @@ export class CodexAppServerWorker {
368
494
  }
369
495
  };
370
496
 
497
+ const finishWithError = (error: Error) => {
498
+ if (settled) return;
499
+ settled = true;
500
+ cleanup();
501
+ reject(error);
502
+ };
503
+
504
+ const finishWithResult = (result: TurnResult) => {
505
+ if (settled) return;
506
+ settled = true;
507
+ cleanup();
508
+ resolve(result);
509
+ };
510
+
511
+ abort = finishWithError;
512
+ this.activeTurnAbort = abort;
513
+
371
514
  this.rpc!.onNotification("item/completed", (p) => {
372
515
  if (p.threadId !== threadId) return;
373
516
  if (p.item.type === "agentMessage") text = p.item.text;
@@ -375,22 +518,23 @@ export class CodexAppServerWorker {
375
518
 
376
519
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
377
520
  if (p.threadId !== threadId) return;
378
- usage = p.tokenUsage.total;
521
+ // thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
522
+ // 이 요청 자체의 cache 적중률이 희석된다(예: turn1 cold 0% + turn2 90% → 48% 로 기록).
523
+ // .last 는 이번 turn 만의 usage 라 request_log 가 그 요청의 실제 토큰을 정확히 반영한다.
524
+ usage = p.tokenUsage.last;
379
525
  });
380
526
 
381
527
  this.rpc!.onNotification("turn/completed", (p) => {
382
528
  if (p.threadId !== threadId) return;
383
529
  durationMs = p.turn.durationMs ?? 0;
384
- cleanup();
385
- if (p.turn.status === "completed") resolve({ text, usage, durationMs, model });
386
- else reject(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
530
+ if (p.turn.status === "completed") finishWithResult({ text, usage, durationMs, model });
531
+ else finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
387
532
  });
388
533
 
389
534
  this.rpc!.onNotification("error", (p) => {
390
535
  if (p.threadId !== threadId) return;
391
536
  if (!p.willRetry) {
392
- cleanup();
393
- reject(new Error(`codex error: ${p.error.message}`));
537
+ finishWithError(new Error(`codex error: ${p.error.message}`));
394
538
  }
395
539
  });
396
540
  });
@@ -402,10 +546,10 @@ export class CodexAppServerWorker {
402
546
  cb: StreamCallbacks,
403
547
  ): Promise<void> {
404
548
  return new Promise<void>((resolve, reject) => {
549
+ let settled = false;
550
+ let abort: ActiveTurnAbort;
405
551
  const timeout = setTimeout(() => {
406
- cleanup();
407
- cb.onError(new Error("turn timeout (600s)"));
408
- reject(new Error("turn timeout (600s)"));
552
+ finishWithError(new Error("turn timeout (600s)"));
409
553
  }, 600_000);
410
554
 
411
555
  let text = "";
@@ -419,6 +563,7 @@ export class CodexAppServerWorker {
419
563
 
420
564
  const cleanup = () => {
421
565
  clearTimeout(timeout);
566
+ if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
422
567
  for (const n of [
423
568
  "item/completed",
424
569
  "item/agentMessage/delta",
@@ -430,6 +575,25 @@ export class CodexAppServerWorker {
430
575
  }
431
576
  };
432
577
 
578
+ const finishWithError = (error: Error) => {
579
+ if (settled) return;
580
+ settled = true;
581
+ cleanup();
582
+ cb.onError(error);
583
+ reject(error);
584
+ };
585
+
586
+ const finishWithComplete = (result: TurnResult) => {
587
+ if (settled) return;
588
+ settled = true;
589
+ cleanup();
590
+ cb.onComplete(result);
591
+ resolve();
592
+ };
593
+
594
+ abort = finishWithError;
595
+ this.activeTurnAbort = abort;
596
+
433
597
  this.rpc!.onNotification("item/agentMessage/delta", (p) => {
434
598
  if (p.threadId !== threadId) return;
435
599
  text += p.delta;
@@ -443,29 +607,25 @@ export class CodexAppServerWorker {
443
607
 
444
608
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
445
609
  if (p.threadId !== threadId) return;
446
- usage = p.tokenUsage.total;
610
+ // thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
611
+ // 이 요청 자체의 cache 적중률이 희석된다(예: turn1 cold 0% + turn2 90% → 48% 로 기록).
612
+ // .last 는 이번 turn 만의 usage 라 request_log 가 그 요청의 실제 토큰을 정확히 반영한다.
613
+ usage = p.tokenUsage.last;
447
614
  });
448
615
 
449
616
  this.rpc!.onNotification("turn/completed", (p) => {
450
617
  if (p.threadId !== threadId) return;
451
- cleanup();
452
618
  if (p.turn.status === "completed") {
453
- cb.onComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
454
- resolve();
619
+ finishWithComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
455
620
  } else {
456
- const err = new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`);
457
- cb.onError(err);
458
- reject(err);
621
+ finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
459
622
  }
460
623
  });
461
624
 
462
625
  this.rpc!.onNotification("error", (p) => {
463
626
  if (p.threadId !== threadId) return;
464
627
  if (!p.willRetry) {
465
- cleanup();
466
- const err = new Error(`codex error: ${p.error.message}`);
467
- cb.onError(err);
468
- reject(err);
628
+ finishWithError(new Error(`codex error: ${p.error.message}`));
469
629
  }
470
630
  });
471
631
  });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { type OpenAICredentials } from "../../../application/token/token.types";
4
+ import { type CodexAppServerWorker } from "./codex-worker";
5
+ import { OpenAIDispatcher } from "./openai-dispatcher";
6
+
7
+ function credentials(overrides: Partial<OpenAICredentials> = {}): OpenAICredentials {
8
+ return {
9
+ accessToken: "access",
10
+ refreshToken: "refresh",
11
+ accessTokenExpiresAt: Date.now() + 60_000,
12
+ accountId: "account",
13
+ planType: "plus",
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ function fakeWorker(reusable: boolean): CodexAppServerWorker {
19
+ return {
20
+ kill: vi.fn(async () => {}),
21
+ canReuseForToken: vi.fn(() => reusable),
22
+ updateTokenState: vi.fn(),
23
+ } as unknown as CodexAppServerWorker;
24
+ }
25
+
26
+ describe("OpenAIDispatcher token updates", () => {
27
+ it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
28
+ const dispatcher = new OpenAIDispatcher();
29
+ const worker = fakeWorker(true);
30
+ const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
31
+ dispatcher.workerPool.set(1, [worker]);
32
+
33
+ const rotated = credentials({
34
+ accessToken: "new-access",
35
+ refreshToken: "new-refresh",
36
+ idToken: "new-id",
37
+ accessTokenExpiresAt: Date.now() + 120_000,
38
+ });
39
+ await dispatcher.onTokenUpdated(1, "renamed-token", rotated);
40
+
41
+ expect(worker.kill).not.toHaveBeenCalled();
42
+ expect(worker.updateTokenState).toHaveBeenCalledWith("renamed-token", rotated);
43
+ expect(spawnWorkers).not.toHaveBeenCalled();
44
+ expect(dispatcher.workerPool.get(1)).toEqual([worker]);
45
+ });
46
+
47
+ it("restarts workers when the OpenAI login identity changes", async () => {
48
+ const dispatcher = new OpenAIDispatcher();
49
+ const worker = fakeWorker(false);
50
+ const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
51
+ dispatcher.workerPool.set(1, [worker]);
52
+
53
+ const changedIdentity = credentials({ accountId: "other-account" });
54
+ await dispatcher.onTokenUpdated(1, "token", changedIdentity);
55
+
56
+ expect(worker.kill).toHaveBeenCalledTimes(1);
57
+ expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity);
58
+ });
59
+ });