@cartanova/qgrid-cli 2.3.5 → 2.3.7

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 (92) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +15 -12
  2. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +40 -20
  5. package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
  6. package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
  7. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  8. package/bundle/dist/application/request-log/request-log.model.js +12 -7
  9. package/bundle/dist/application/sonamu.generated.js +11 -3
  10. package/bundle/dist/application/sonamu.generated.sso.js +7 -2
  11. package/bundle/dist/application/token/token.model.js +10 -2
  12. package/bundle/dist/application/token/token.types.js +5 -3
  13. package/bundle/dist/i18n/sd.generated.js +4 -1
  14. package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
  15. package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
  16. package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
  17. package/bundle/dist/sonamu.config.js +13 -6
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
  19. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
  20. package/bundle/dist/utils/providers/anthropic/claude-session.js +8 -3
  21. package/bundle/dist/utils/providers/common/ttft.js +25 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
  24. package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
  25. package/bundle/src/application/qgrid/oauth.ts +17 -7
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
  29. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
  30. package/bundle/src/application/qgrid/qgrid.frame.test.ts +172 -0
  31. package/bundle/src/application/qgrid/qgrid.frame.ts +45 -20
  32. package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
  33. package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
  34. package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
  35. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  36. package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
  37. package/bundle/src/application/request-log/request-log.entity.json +3 -0
  38. package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
  39. package/bundle/src/application/request-log/request-log.model.ts +20 -6
  40. package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
  41. package/bundle/src/application/sonamu.generated.http +7 -2
  42. package/bundle/src/application/sonamu.generated.sso.ts +5 -0
  43. package/bundle/src/application/sonamu.generated.ts +11 -1
  44. package/bundle/src/application/token/token.entity.json +8 -2
  45. package/bundle/src/application/token/token.model.test.ts +69 -0
  46. package/bundle/src/application/token/token.model.ts +8 -1
  47. package/bundle/src/application/token/token.types.test.ts +34 -0
  48. package/bundle/src/application/token/token.types.ts +5 -0
  49. package/bundle/src/i18n/sd.generated.ts +3 -0
  50. package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
  51. package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
  52. package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
  53. package/bundle/src/sonamu.config.ts +8 -1
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
  56. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
  57. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
  58. package/bundle/src/utils/providers/anthropic/claude-session.ts +7 -2
  59. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
  60. package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
  61. package/bundle/src/utils/providers/common/ttft.ts +26 -0
  62. package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
  63. package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
  65. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
  66. package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
  67. package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
  68. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
  69. package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
  70. package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
  71. package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
  72. package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
  73. package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
  74. package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
  75. package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
  76. package/bundle/web-dist/client/index.html +3 -3
  77. package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
  78. package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
  79. package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
  80. package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
  81. package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
  82. package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
  83. package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
  84. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  85. package/dist/cli.js +67 -15
  86. package/package.json +1 -1
  87. package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
  88. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
  89. package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
  90. package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
  91. package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
  92. /package/bundle/web-dist/server/assets/{cost-DzjfVtUr.js → cost-CSH_t17I.js} +0 -0
@@ -0,0 +1,89 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { readAnthropicQuotaUsage } from "./anthropic-quota";
4
+
5
+ const { fetchUsageWithMetaMock } = vi.hoisted(() => ({
6
+ fetchUsageWithMetaMock: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("../../../application/qgrid/oauth", () => ({
10
+ fetchUsageWithMeta: fetchUsageWithMetaMock,
11
+ }));
12
+
13
+ describe("readAnthropicQuotaUsage", () => {
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ vi.setSystemTime(new Date("2026-06-30T12:00:05.000Z"));
17
+ fetchUsageWithMetaMock.mockReset();
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+
24
+ it("uses API utilization as 0..100 percent", async () => {
25
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
26
+ data: { five_hour: { utilization: 17, resets_at: null } },
27
+ cachedAt: Date.now() - 1_250,
28
+ });
29
+
30
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toEqual({
31
+ kind: "ok",
32
+ utilizationPct: 17,
33
+ cacheAgeMs: 1_250,
34
+ });
35
+ });
36
+
37
+ it("keeps low percent utilization unchanged", async () => {
38
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
39
+ data: { five_hour: { utilization: 1, resets_at: null } },
40
+ cachedAt: Date.now() - 500,
41
+ });
42
+
43
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
44
+ kind: "ok",
45
+ utilizationPct: 1,
46
+ cacheAgeMs: 500,
47
+ });
48
+ });
49
+
50
+ it("treats utilization 0 as a successful lookup", async () => {
51
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
52
+ data: { five_hour: { utilization: 0, resets_at: null } },
53
+ cachedAt: Date.now(),
54
+ });
55
+
56
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
57
+ kind: "ok",
58
+ utilizationPct: 0,
59
+ });
60
+ });
61
+
62
+ it("converts API errors and rejected fetches into lookup_failed", async () => {
63
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
64
+ data: { error: "rate limit API unavailable" },
65
+ cachedAt: Date.now(),
66
+ });
67
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
68
+ kind: "lookup_failed",
69
+ reason: "rate limit API unavailable",
70
+ });
71
+
72
+ fetchUsageWithMetaMock.mockRejectedValueOnce(new Error("timeout"));
73
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
74
+ kind: "lookup_failed",
75
+ reason: "timeout",
76
+ });
77
+ });
78
+
79
+ it("treats missing five_hour utilization as lookup_failed", async () => {
80
+ fetchUsageWithMetaMock.mockResolvedValueOnce({
81
+ data: { five_hour: { utilization: null, resets_at: null } },
82
+ cachedAt: Date.now(),
83
+ });
84
+
85
+ await expect(readAnthropicQuotaUsage("access-token")).resolves.toMatchObject({
86
+ kind: "lookup_failed",
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,27 @@
1
+ import { fetchUsageWithMeta } from "../../../application/qgrid/oauth";
2
+
3
+ export type AnthropicQuotaUsageResult =
4
+ | { kind: "ok"; utilizationPct: number; cacheAgeMs: number }
5
+ | { kind: "lookup_failed"; reason: string };
6
+
7
+ export async function readAnthropicQuotaUsage(
8
+ accessToken: string,
9
+ ): Promise<AnthropicQuotaUsageResult> {
10
+ try {
11
+ const { data, cachedAt } = await fetchUsageWithMeta(accessToken);
12
+ if (data.error) return { kind: "lookup_failed", reason: data.error };
13
+
14
+ const utilization = data.five_hour?.utilization;
15
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) {
16
+ return { kind: "lookup_failed", reason: "five_hour utilization unavailable" };
17
+ }
18
+
19
+ return {
20
+ kind: "ok",
21
+ utilizationPct: utilization,
22
+ cacheAgeMs: Math.max(Date.now() - cachedAt, 0),
23
+ };
24
+ } catch (e) {
25
+ return { kind: "lookup_failed", reason: (e as Error).message };
26
+ }
27
+ }
@@ -21,6 +21,7 @@ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
22
22
  import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
23
23
  import { type UserInput } from "../../../codex-protocol/v2/UserInput";
24
+ import { createTtftTracker } from "../common/ttft";
24
25
  import {
25
26
  ANTHROPIC_CLAUDE_CWD,
26
27
  anthropicConfigDir,
@@ -111,6 +112,7 @@ export interface ClaudeSessionRequest {
111
112
  export interface ClaudeSessionResult extends ClaudeStreamResult {
112
113
  sessionId: string;
113
114
  workerId: number;
115
+ ttftMs: number | null;
114
116
  }
115
117
 
116
118
  // per-token CLAUDE_CONFIG_DIR 보장(격리). 경로 규칙은 anthropicConfigDir(순수 함수)이 소유 —
@@ -305,6 +307,8 @@ export function runClaudeSession(
305
307
  let settled = false;
306
308
  let buffer = "";
307
309
  const streamState: ClaudeStreamJsonState = {};
310
+ const ttftTracker = createTtftTracker();
311
+ const onTrackedDelta = ttftTracker.wrapDelta(onDelta);
308
312
  // bounded stderr 버퍼(최근 ~4KB만 유지 — 무한 누적 방지).
309
313
  let stderrBuf = "";
310
314
  const STDERR_CAP = 4096;
@@ -339,7 +343,7 @@ export function runClaudeSession(
339
343
  clearTimeout(timer);
340
344
  cleanupAbort?.();
341
345
  cleanupSpawnFiles();
342
- resolve({ ...result, sessionId, workerId });
346
+ resolve({ ...result, sessionId, workerId, ttftMs: ttftTracker.value() });
343
347
  };
344
348
 
345
349
  const onAbort = () => {
@@ -360,7 +364,7 @@ export function runClaudeSession(
360
364
  const lines = buffer.split("\n");
361
365
  buffer = lines.pop() ?? "";
362
366
  for (const line of lines) {
363
- const result = handleStreamJsonLine(line, onDelta, {
367
+ const result = handleStreamJsonLine(line, onTrackedDelta, {
364
368
  structuredOutput: useStructured,
365
369
  state: streamState,
366
370
  });
@@ -389,6 +393,7 @@ export function runClaudeSession(
389
393
  });
390
394
 
391
395
  // stdin 으로 입력 JSONL 흘리고 닫는다.
396
+ ttftTracker.markStart();
392
397
  child.stdin?.write(stdinPayload);
393
398
  child.stdin?.end();
394
399
  });
@@ -49,6 +49,7 @@ export interface GenerateResult {
49
49
  tokenName: string;
50
50
  usage: ProviderTokenUsageBreakdown;
51
51
  durationMs: number;
52
+ ttftMs?: number | null;
52
53
  // Provider 가 직접 산출한 비용. Anthropic Claude Code 는 total_cost_usd 를 주므로 이 값을
53
54
  // 우선 사용하고, 없으면 상위가 모델별 가격표로 계산한다.
54
55
  costUsd?: number;
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createTtftTracker } from "./ttft";
4
+
5
+ describe("createTtftTracker", () => {
6
+ it("records the first delta once relative to the marked start", () => {
7
+ let now = 100;
8
+ const tracker = createTtftTracker(() => now);
9
+ const deltas: string[] = [];
10
+ const onDelta = tracker.wrapDelta((text) => deltas.push(text));
11
+
12
+ tracker.markStart();
13
+ now = 135;
14
+ onDelta("a");
15
+ now = 200;
16
+ onDelta("b");
17
+
18
+ expect(tracker.value()).toBe(35);
19
+ expect(deltas).toEqual(["a", "b"]);
20
+ });
21
+
22
+ it("keeps null when no delta is recorded", () => {
23
+ const tracker = createTtftTracker(() => 100);
24
+
25
+ tracker.markStart();
26
+
27
+ expect(tracker.value()).toBeNull();
28
+ });
29
+
30
+ it("does not treat a delta before start as zero", () => {
31
+ const tracker = createTtftTracker(() => 100);
32
+
33
+ tracker.recordFirstDelta();
34
+
35
+ expect(tracker.value()).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,26 @@
1
+ export function createTtftTracker(now: () => number = Date.now): {
2
+ markStart: () => void;
3
+ recordFirstDelta: () => void;
4
+ value: () => number | null;
5
+ wrapDelta: (onDelta: (text: string) => void) => (text: string) => void;
6
+ } {
7
+ let startedAt: number | null = null;
8
+ let ttftMs: number | null = null;
9
+
10
+ const recordFirstDelta = () => {
11
+ if (startedAt === null || ttftMs !== null) return;
12
+ ttftMs = Math.max(0, now() - startedAt);
13
+ };
14
+
15
+ return {
16
+ markStart: () => {
17
+ startedAt = now();
18
+ },
19
+ recordFirstDelta,
20
+ value: () => ttftMs,
21
+ wrapDelta: (onDelta) => (text) => {
22
+ recordFirstDelta();
23
+ onDelta(text);
24
+ },
25
+ };
26
+ }
@@ -53,6 +53,41 @@ function createWorkerWithRequestSpy() {
53
53
  return { worker, request };
54
54
  }
55
55
 
56
+ function createWorkerWithNotificationRpc(
57
+ onRequest: (
58
+ method: string,
59
+ params: unknown,
60
+ handlers: Map<string, (params: never) => void>,
61
+ ) => Promise<unknown>,
62
+ ) {
63
+ const worker = new CodexAppServerWorker({
64
+ tokenId: 1,
65
+ tokenName: "token",
66
+ accessToken: "access",
67
+ accountId: "account",
68
+ });
69
+ const handlers = new Map<string, (params: never) => void>();
70
+ const request = vi.fn((method: string, params?: unknown) => onRequest(method, params, handlers));
71
+ const rpc = {
72
+ request,
73
+ onNotification(method: string, handler: (params: never) => void) {
74
+ handlers.set(method, handler);
75
+ },
76
+ };
77
+ (
78
+ worker as unknown as {
79
+ rpc: typeof rpc;
80
+ ready: boolean;
81
+ }
82
+ ).rpc = rpc;
83
+ (
84
+ worker as unknown as {
85
+ ready: boolean;
86
+ }
87
+ ).ready = true;
88
+ return { worker, request, handlers };
89
+ }
90
+
56
91
  describe("CodexAppServerWorker active turn cleanup", () => {
57
92
  it("rejects a non-streaming turn when the worker process exits", async () => {
58
93
  const { worker } = createWorkerWithFakeRpc();
@@ -101,6 +136,119 @@ describe("CodexAppServerWorker active turn cleanup", () => {
101
136
  });
102
137
  });
103
138
 
139
+ describe("CodexAppServerWorker TTFT", () => {
140
+ it("captures fast non-stream deltas registered before turn/start completes", async () => {
141
+ let now = 1_000;
142
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
143
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
144
+ if (method === "thread/start") {
145
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
146
+ }
147
+ if (method === "turn/start") {
148
+ now = 1_025;
149
+ handlers.get("item/agentMessage/delta")?.({
150
+ threadId: "thread-1",
151
+ delta: "h",
152
+ } as never);
153
+ now = 1_050;
154
+ handlers.get("item/agentMessage/delta")?.({
155
+ threadId: "thread-1",
156
+ delta: "i",
157
+ } as never);
158
+ handlers.get("turn/completed")?.({
159
+ threadId: "thread-1",
160
+ turn: { status: "completed", durationMs: 90 },
161
+ } as never);
162
+ return { turn: { id: "turn-1" } };
163
+ }
164
+ throw new Error(`unexpected request: ${method}`);
165
+ });
166
+
167
+ try {
168
+ const result = await worker.executeTurn({
169
+ input: [{ type: "text", text: "hi", text_elements: [] }],
170
+ });
171
+
172
+ expect(result.text).toBe("hi");
173
+ expect(result.ttftMs).toBe(25);
174
+ } finally {
175
+ nowSpy.mockRestore();
176
+ }
177
+ });
178
+
179
+ it("keeps non-stream ttft null when no delta arrives", async () => {
180
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
181
+ if (method === "thread/start") {
182
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
183
+ }
184
+ if (method === "turn/start") {
185
+ handlers.get("turn/completed")?.({
186
+ threadId: "thread-1",
187
+ turn: { status: "completed", durationMs: 90 },
188
+ } as never);
189
+ return { turn: { id: "turn-1" } };
190
+ }
191
+ throw new Error(`unexpected request: ${method}`);
192
+ });
193
+
194
+ const result = await worker.executeTurn({
195
+ input: [{ type: "text", text: "hi", text_elements: [] }],
196
+ });
197
+
198
+ expect(result.ttftMs).toBeNull();
199
+ });
200
+
201
+ it("captures stream ttft from the first delta only", async () => {
202
+ let now = 2_000;
203
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
204
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
205
+ if (method === "thread/start") {
206
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
207
+ }
208
+ if (method === "turn/start") {
209
+ now = 2_030;
210
+ handlers.get("item/agentMessage/delta")?.({
211
+ threadId: "thread-1",
212
+ delta: "a",
213
+ } as never);
214
+ now = 2_080;
215
+ handlers.get("item/agentMessage/delta")?.({
216
+ threadId: "thread-1",
217
+ delta: "b",
218
+ } as never);
219
+ handlers.get("turn/completed")?.({
220
+ threadId: "thread-1",
221
+ turn: { status: "completed", durationMs: 120 },
222
+ } as never);
223
+ return { turn: { id: "turn-1" } };
224
+ }
225
+ throw new Error(`unexpected request: ${method}`);
226
+ });
227
+ const callbacks: StreamCallbacks = {
228
+ onDelta: vi.fn(),
229
+ onComplete: vi.fn(),
230
+ onError: vi.fn(),
231
+ };
232
+
233
+ try {
234
+ await worker.executeTurnStream(
235
+ {
236
+ input: [{ type: "text", text: "hi", text_elements: [] }],
237
+ },
238
+ callbacks,
239
+ );
240
+
241
+ expect(callbacks.onDelta).toHaveBeenCalledWith("a");
242
+ expect(callbacks.onDelta).toHaveBeenCalledWith("b");
243
+ expect(callbacks.onComplete).toHaveBeenCalledWith(
244
+ expect.objectContaining({ text: "ab", ttftMs: 30 }),
245
+ );
246
+ } finally {
247
+ nowSpy.mockRestore();
248
+ }
249
+ });
250
+ });
251
+
104
252
  describe("CodexAppServerWorker prompt prefix", () => {
105
253
  it("uses the same base instructions with and without an output schema", async () => {
106
254
  type WorkerInternals = {
@@ -10,6 +10,7 @@ import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageB
10
10
  import { type TurnStartParams } from "../../../codex-protocol/v2/TurnStartParams";
11
11
  import { type TurnStartResponse } from "../../../codex-protocol/v2/TurnStartResponse";
12
12
  import { type StreamCallbacks as CommonStreamCallbacks } from "../common/provider-dispatcher";
13
+ import { createTtftTracker } from "../common/ttft";
13
14
  import { CodexRpcClient } from "./codex-rpc";
14
15
 
15
16
  const logger = getLogger(["qgrid", "codex-worker"]);
@@ -44,6 +45,7 @@ export interface TurnResult {
44
45
  text: string;
45
46
  usage: TokenUsageBreakdown;
46
47
  durationMs: number;
48
+ ttftMs: number | null;
47
49
  model: string;
48
50
  }
49
51
 
@@ -387,8 +389,17 @@ export class CodexAppServerWorker {
387
389
  } else {
388
390
  ({ threadId, model } = await this.createThread(req));
389
391
  }
390
- await this.startTurnOnThread(threadId, req);
391
- const result = await this.consumeTurnNotifications(threadId, model);
392
+ const ttftTracker = createTtftTracker();
393
+ const resultPromise = this.consumeTurnNotifications(threadId, model, ttftTracker);
394
+ try {
395
+ ttftTracker.markStart();
396
+ await this.startTurnOnThread(threadId, req);
397
+ } catch (e) {
398
+ this.failActiveTurn(e as Error);
399
+ await resultPromise.catch(() => {});
400
+ throw e;
401
+ }
402
+ const result = await resultPromise;
392
403
  return { ...result, threadId };
393
404
  }
394
405
 
@@ -405,10 +416,20 @@ export class CodexAppServerWorker {
405
416
  } else {
406
417
  ({ threadId, model } = await this.createThread(req));
407
418
  }
408
- const { turnId } = await this.startTurnOnThread(threadId, req);
409
419
  cb.onThreadId?.(threadId);
420
+ const ttftTracker = createTtftTracker();
421
+ const resultPromise = this.consumeStreamNotifications(threadId, model, cb, ttftTracker);
422
+ let turnId: string;
423
+ try {
424
+ ttftTracker.markStart();
425
+ ({ turnId } = await this.startTurnOnThread(threadId, req));
426
+ } catch (e) {
427
+ this.failActiveTurn(e as Error);
428
+ await resultPromise.catch(() => {});
429
+ throw e;
430
+ }
410
431
  cb.onTurnId?.(turnId);
411
- await this.consumeStreamNotifications(threadId, model, cb);
432
+ await resultPromise;
412
433
  return { threadId };
413
434
  }
414
435
 
@@ -456,7 +477,11 @@ export class CodexAppServerWorker {
456
477
  abort?.(error);
457
478
  }
458
479
 
459
- private consumeTurnNotifications(threadId: string, model: string): Promise<TurnResult> {
480
+ private consumeTurnNotifications(
481
+ threadId: string,
482
+ model: string,
483
+ ttftTracker = createTtftTracker(),
484
+ ): Promise<TurnResult> {
460
485
  return new Promise<TurnResult>((resolve, reject) => {
461
486
  let settled = false;
462
487
  let abort: ActiveTurnAbort;
@@ -510,6 +535,12 @@ export class CodexAppServerWorker {
510
535
  if (p.item.type === "agentMessage") text = p.item.text;
511
536
  });
512
537
 
538
+ this.rpc!.onNotification("item/agentMessage/delta", (p) => {
539
+ if (p.threadId !== threadId) return;
540
+ ttftTracker.recordFirstDelta();
541
+ text += p.delta;
542
+ });
543
+
513
544
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
514
545
  if (p.threadId !== threadId) return;
515
546
  // thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
@@ -521,8 +552,11 @@ export class CodexAppServerWorker {
521
552
  this.rpc!.onNotification("turn/completed", (p) => {
522
553
  if (p.threadId !== threadId) return;
523
554
  durationMs = p.turn.durationMs ?? 0;
524
- if (p.turn.status === "completed") finishWithResult({ text, usage, durationMs, model });
525
- else finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
555
+ if (p.turn.status === "completed") {
556
+ finishWithResult({ text, usage, durationMs, ttftMs: ttftTracker.value(), model });
557
+ } else {
558
+ finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
559
+ }
526
560
  });
527
561
 
528
562
  this.rpc!.onNotification("error", (p) => {
@@ -538,6 +572,7 @@ export class CodexAppServerWorker {
538
572
  threadId: string,
539
573
  model: string,
540
574
  cb: StreamCallbacks,
575
+ ttftTracker = createTtftTracker(),
541
576
  ): Promise<void> {
542
577
  return new Promise<void>((resolve, reject) => {
543
578
  let settled = false;
@@ -590,6 +625,7 @@ export class CodexAppServerWorker {
590
625
 
591
626
  this.rpc!.onNotification("item/agentMessage/delta", (p) => {
592
627
  if (p.threadId !== threadId) return;
628
+ ttftTracker.recordFirstDelta();
593
629
  text += p.delta;
594
630
  cb.onDelta(p.delta);
595
631
  });
@@ -610,7 +646,13 @@ export class CodexAppServerWorker {
610
646
  this.rpc!.onNotification("turn/completed", (p) => {
611
647
  if (p.threadId !== threadId) return;
612
648
  if (p.turn.status === "completed") {
613
- finishWithComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
649
+ finishWithComplete({
650
+ text,
651
+ usage,
652
+ durationMs: p.turn.durationMs ?? 0,
653
+ ttftMs: ttftTracker.value(),
654
+ model,
655
+ });
614
656
  } else {
615
657
  finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
616
658
  }