@kodelyth/codex 2026.5.40 → 2026.5.42

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 (178) hide show
  1. package/dist/client-ChMX13_o.js +642 -0
  2. package/dist/client-factory-D3dIsp4Y.js +9 -0
  3. package/dist/command-formatters-BRW7_Nu7.js +519 -0
  4. package/dist/command-handlers-P2IqtXaZ.js +1462 -0
  5. package/dist/compact-baos5flR.js +329 -0
  6. package/dist/computer-use-VfLvTMaa.js +367 -0
  7. package/dist/config-CezENx_E.js +510 -0
  8. package/dist/doctor-contract-api.js +53 -0
  9. package/dist/harness.js +51 -0
  10. package/dist/index.js +1133 -0
  11. package/dist/media-understanding-provider.js +335 -0
  12. package/dist/models-B9DhrIwD.js +110 -0
  13. package/dist/node-cli-sessions-De4_DuFw.js +1216 -0
  14. package/dist/plugin-activation-BlMuJeXz.js +452 -0
  15. package/dist/prompt-overlay.js +12 -0
  16. package/dist/protocol-C9UWI98H.js +9 -0
  17. package/dist/protocol-validators-BGBspNmF.js +5988 -0
  18. package/dist/provider-catalog.js +84 -0
  19. package/dist/provider-discovery.js +33 -0
  20. package/dist/provider.js +150 -0
  21. package/dist/rate-limit-cache-CHuacE27.js +24 -0
  22. package/dist/request-CTQKUxaa.js +89 -0
  23. package/dist/rolldown-runtime-DUslC3ob.js +14 -0
  24. package/dist/run-attempt-DqV2OU1R.js +5366 -0
  25. package/dist/session-binding-3PzU7ZTW.js +222 -0
  26. package/dist/shared-client-Cnyr9dyT.js +631 -0
  27. package/dist/side-question-CP5XlA0U.js +667 -0
  28. package/dist/test-api.js +45 -0
  29. package/dist/thread-lifecycle-DBJetBuV.js +1561 -0
  30. package/dist/vision-tools-Cl_5a93K.js +1379 -0
  31. package/doctor-contract-api.test.ts +44 -0
  32. package/doctor-contract-api.ts +68 -0
  33. package/harness.ts +72 -0
  34. package/index.test.ts +230 -0
  35. package/index.ts +66 -0
  36. package/klaw.plugin.json +24 -85
  37. package/media-understanding-provider.test.ts +486 -0
  38. package/media-understanding-provider.ts +521 -0
  39. package/package.json +3 -3
  40. package/prompt-overlay-runtime-contract.test.ts +48 -0
  41. package/prompt-overlay.ts +21 -0
  42. package/provider-catalog.ts +83 -0
  43. package/provider-discovery.ts +45 -0
  44. package/provider.test.ts +384 -0
  45. package/provider.ts +243 -0
  46. package/src/app-server/app-inventory-cache.test.ts +176 -0
  47. package/src/app-server/app-inventory-cache.ts +324 -0
  48. package/src/app-server/approval-bridge.test.ts +1471 -0
  49. package/src/app-server/approval-bridge.ts +1211 -0
  50. package/src/app-server/auth-bridge.test.ts +1449 -0
  51. package/src/app-server/auth-bridge.ts +614 -0
  52. package/src/app-server/auth-profile-runtime-contract.test.ts +239 -0
  53. package/src/app-server/capabilities.ts +27 -0
  54. package/src/app-server/client-factory.ts +24 -0
  55. package/src/app-server/client.test.ts +563 -0
  56. package/src/app-server/client.ts +715 -0
  57. package/src/app-server/compact.test.ts +710 -0
  58. package/src/app-server/compact.ts +500 -0
  59. package/src/app-server/computer-use.test.ts +788 -0
  60. package/src/app-server/computer-use.ts +683 -0
  61. package/src/app-server/config.test.ts +879 -0
  62. package/src/app-server/config.ts +1038 -0
  63. package/src/app-server/context-engine-projection.test.ts +252 -0
  64. package/src/app-server/context-engine-projection.ts +403 -0
  65. package/src/app-server/delivery-no-reply-runtime-contract.test.ts +80 -0
  66. package/src/app-server/dynamic-tool-diagnostics.ts +73 -0
  67. package/src/app-server/dynamic-tool-profile.ts +69 -0
  68. package/src/app-server/dynamic-tools.test.ts +1302 -0
  69. package/src/app-server/dynamic-tools.ts +623 -0
  70. package/src/app-server/elicitation-bridge.test.ts +1056 -0
  71. package/src/app-server/elicitation-bridge.ts +783 -0
  72. package/src/app-server/event-projector.test.ts +2668 -0
  73. package/src/app-server/event-projector.ts +2057 -0
  74. package/src/app-server/image-payload-sanitizer.test.ts +49 -0
  75. package/src/app-server/image-payload-sanitizer.ts +167 -0
  76. package/src/app-server/klaw-owned-tool-runtime-contract.test.ts +456 -0
  77. package/src/app-server/local-runtime-attribution.ts +39 -0
  78. package/src/app-server/managed-binary.test.ts +139 -0
  79. package/src/app-server/managed-binary.ts +193 -0
  80. package/src/app-server/models.test.ts +246 -0
  81. package/src/app-server/models.ts +172 -0
  82. package/src/app-server/native-hook-relay.test.ts +271 -0
  83. package/src/app-server/native-hook-relay.ts +150 -0
  84. package/src/app-server/native-subagent-task-mirror.test.ts +573 -0
  85. package/src/app-server/native-subagent-task-mirror.ts +497 -0
  86. package/src/app-server/outcome-fallback-runtime-contract.test.ts +404 -0
  87. package/src/app-server/plugin-activation.test.ts +336 -0
  88. package/src/app-server/plugin-activation.ts +283 -0
  89. package/src/app-server/plugin-app-cache-key.ts +74 -0
  90. package/src/app-server/plugin-approval-roundtrip.ts +122 -0
  91. package/src/app-server/plugin-inventory.test.ts +355 -0
  92. package/src/app-server/plugin-inventory.ts +357 -0
  93. package/src/app-server/plugin-thread-config.test.ts +865 -0
  94. package/src/app-server/plugin-thread-config.ts +455 -0
  95. package/src/app-server/protocol-generated/json/DynamicToolCallParams.json +33 -0
  96. package/src/app-server/protocol-generated/json/v2/ErrorNotification.json +199 -0
  97. package/src/app-server/protocol-generated/json/v2/GetAccountResponse.json +102 -0
  98. package/src/app-server/protocol-generated/json/v2/ModelListResponse.json +227 -0
  99. package/src/app-server/protocol-generated/json/v2/ThreadResumeResponse.json +2630 -0
  100. package/src/app-server/protocol-generated/json/v2/ThreadStartResponse.json +2630 -0
  101. package/src/app-server/protocol-generated/json/v2/TurnCompletedNotification.json +1659 -0
  102. package/src/app-server/protocol-generated/json/v2/TurnStartResponse.json +1655 -0
  103. package/src/app-server/protocol-validators.test.ts +75 -0
  104. package/src/app-server/protocol-validators.ts +203 -0
  105. package/src/app-server/protocol.ts +520 -0
  106. package/src/app-server/rate-limit-cache.ts +48 -0
  107. package/src/app-server/rate-limits.test.ts +202 -0
  108. package/src/app-server/rate-limits.ts +583 -0
  109. package/src/app-server/request.ts +73 -0
  110. package/src/app-server/run-attempt.context-engine.test.ts +1004 -0
  111. package/src/app-server/run-attempt.test.ts +9477 -0
  112. package/src/app-server/run-attempt.ts +4683 -0
  113. package/src/app-server/run-attempt.vision-tools.test.ts +35 -0
  114. package/src/app-server/schema-normalization-runtime-contract.test.ts +206 -0
  115. package/src/app-server/session-binding.test.ts +303 -0
  116. package/src/app-server/session-binding.ts +398 -0
  117. package/src/app-server/session-history.ts +44 -0
  118. package/src/app-server/shared-client.test.ts +589 -0
  119. package/src/app-server/shared-client.ts +289 -0
  120. package/src/app-server/side-question.test.ts +1175 -0
  121. package/src/app-server/side-question.ts +1007 -0
  122. package/src/app-server/test-support.ts +48 -0
  123. package/src/app-server/thread-lifecycle.test.ts +447 -0
  124. package/src/app-server/thread-lifecycle.ts +939 -0
  125. package/src/app-server/thread-lifecycle.user-mcp-servers.test.ts +442 -0
  126. package/src/app-server/timeout.ts +9 -0
  127. package/src/app-server/tool-progress-normalization.ts +77 -0
  128. package/src/app-server/trajectory.test.ts +205 -0
  129. package/src/app-server/trajectory.ts +365 -0
  130. package/src/app-server/transcript-mirror.test.ts +524 -0
  131. package/src/app-server/transcript-mirror.ts +208 -0
  132. package/src/app-server/transcript-repair-runtime-contract.test.ts +44 -0
  133. package/src/app-server/transport-stdio.test.ts +171 -0
  134. package/src/app-server/transport-stdio.ts +107 -0
  135. package/src/app-server/transport-websocket.test.ts +69 -0
  136. package/src/app-server/transport-websocket.ts +90 -0
  137. package/src/app-server/transport.ts +117 -0
  138. package/src/app-server/user-input-bridge.test.ts +249 -0
  139. package/src/app-server/user-input-bridge.ts +316 -0
  140. package/src/app-server/version.ts +4 -0
  141. package/src/app-server/vision-tools.ts +12 -0
  142. package/src/command-account.ts +544 -0
  143. package/src/command-formatters.ts +425 -0
  144. package/src/command-handlers.ts +2004 -0
  145. package/src/command-rpc.test.ts +16 -0
  146. package/src/command-rpc.ts +142 -0
  147. package/src/commands.test.ts +3312 -0
  148. package/src/commands.ts +65 -0
  149. package/src/conversation-binding-data.ts +124 -0
  150. package/src/conversation-binding.test.ts +599 -0
  151. package/src/conversation-binding.ts +561 -0
  152. package/src/conversation-control.test.ts +126 -0
  153. package/src/conversation-control.ts +303 -0
  154. package/src/conversation-turn-collector.test.ts +191 -0
  155. package/src/conversation-turn-collector.ts +186 -0
  156. package/src/conversation-turn-input.test.ts +141 -0
  157. package/src/conversation-turn-input.ts +106 -0
  158. package/src/manifest.test.ts +20 -0
  159. package/src/migration/apply.ts +501 -0
  160. package/src/migration/helpers.ts +55 -0
  161. package/src/migration/plan.ts +461 -0
  162. package/src/migration/provider.test.ts +1741 -0
  163. package/src/migration/provider.ts +41 -0
  164. package/src/migration/source.ts +643 -0
  165. package/src/migration/targets.ts +25 -0
  166. package/src/node-cli-sessions.test.ts +180 -0
  167. package/src/node-cli-sessions.ts +711 -0
  168. package/test-api.ts +82 -0
  169. package/tsconfig.json +16 -0
  170. package/doctor-contract-api.js +0 -7
  171. package/harness.js +0 -7
  172. package/index.js +0 -7
  173. package/media-understanding-provider.js +0 -7
  174. package/prompt-overlay.js +0 -7
  175. package/provider-catalog.js +0 -7
  176. package/provider-discovery.js +0 -7
  177. package/provider.js +0 -7
  178. package/test-api.js +0 -7
@@ -0,0 +1,303 @@
1
+ import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js";
2
+ import {
3
+ isCodexFastServiceTier,
4
+ resolveCodexAppServerRuntimeOptions,
5
+ type CodexAppServerApprovalPolicy,
6
+ type CodexAppServerSandboxMode,
7
+ } from "./app-server/config.js";
8
+ import type { CodexServiceTier, CodexThreadResumeResponse } from "./app-server/protocol.js";
9
+ import {
10
+ readCodexAppServerBinding,
11
+ writeCodexAppServerBinding,
12
+ } from "./app-server/session-binding.js";
13
+ import { getSharedCodexAppServerClient } from "./app-server/shared-client.js";
14
+ import { formatCodexDisplayText } from "./command-formatters.js";
15
+
16
+ type ActiveTurn = {
17
+ sessionFile: string;
18
+ threadId: string;
19
+ turnId: string;
20
+ };
21
+
22
+ type CodexAppServerBindingLookup = NonNullable<Parameters<typeof readCodexAppServerBinding>[1]>;
23
+
24
+ type PermissionsMode = "default" | "yolo";
25
+
26
+ const CODEX_CONVERSATION_CONTROL_STATE = Symbol.for("klaw.codex.conversationControl");
27
+
28
+ function getActiveTurns(): Map<string, ActiveTurn> {
29
+ const globalState = globalThis as typeof globalThis & {
30
+ [CODEX_CONVERSATION_CONTROL_STATE]?: Map<string, ActiveTurn>;
31
+ };
32
+ globalState[CODEX_CONVERSATION_CONTROL_STATE] ??= new Map();
33
+ return globalState[CODEX_CONVERSATION_CONTROL_STATE];
34
+ }
35
+
36
+ export function trackCodexConversationActiveTurn(active: ActiveTurn): () => void {
37
+ const activeTurns = getActiveTurns();
38
+ activeTurns.set(active.sessionFile, active);
39
+ return () => {
40
+ const current = activeTurns.get(active.sessionFile);
41
+ if (current?.turnId === active.turnId) {
42
+ activeTurns.delete(active.sessionFile);
43
+ }
44
+ };
45
+ }
46
+
47
+ export function readCodexConversationActiveTurn(sessionFile: string): ActiveTurn | undefined {
48
+ return getActiveTurns().get(sessionFile);
49
+ }
50
+
51
+ export async function stopCodexConversationTurn(params: {
52
+ sessionFile: string;
53
+ pluginConfig?: unknown;
54
+ agentDir?: string;
55
+ config?: CodexAppServerBindingLookup["config"];
56
+ }): Promise<{ stopped: boolean; message: string }> {
57
+ const active = readCodexConversationActiveTurn(params.sessionFile);
58
+ if (!active) {
59
+ return { stopped: false, message: "No active Codex run to stop." };
60
+ }
61
+ const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
62
+ const lookup = buildBindingLookup(params);
63
+ const binding = await readCodexAppServerBinding(params.sessionFile, lookup);
64
+ const client = await getSharedCodexAppServerClient({
65
+ startOptions: runtime.start,
66
+ timeoutMs: runtime.requestTimeoutMs,
67
+ authProfileId: binding?.authProfileId,
68
+ ...lookup,
69
+ });
70
+ await client.request(
71
+ "turn/interrupt",
72
+ {
73
+ threadId: active.threadId,
74
+ turnId: active.turnId,
75
+ },
76
+ { timeoutMs: runtime.requestTimeoutMs },
77
+ );
78
+ return { stopped: true, message: "Codex stop requested." };
79
+ }
80
+
81
+ export async function steerCodexConversationTurn(params: {
82
+ sessionFile: string;
83
+ message: string;
84
+ pluginConfig?: unknown;
85
+ agentDir?: string;
86
+ config?: CodexAppServerBindingLookup["config"];
87
+ }): Promise<{ steered: boolean; message: string }> {
88
+ const active = readCodexConversationActiveTurn(params.sessionFile);
89
+ const text = params.message.trim();
90
+ if (!text) {
91
+ return { steered: false, message: "Usage: /codex steer <message>" };
92
+ }
93
+ if (!active) {
94
+ return { steered: false, message: "No active Codex run to steer." };
95
+ }
96
+ const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
97
+ const lookup = buildBindingLookup(params);
98
+ const binding = await readCodexAppServerBinding(params.sessionFile, lookup);
99
+ const client = await getSharedCodexAppServerClient({
100
+ startOptions: runtime.start,
101
+ timeoutMs: runtime.requestTimeoutMs,
102
+ authProfileId: binding?.authProfileId,
103
+ ...lookup,
104
+ });
105
+ await client.request(
106
+ "turn/steer",
107
+ {
108
+ threadId: active.threadId,
109
+ expectedTurnId: active.turnId,
110
+ input: [{ type: "text", text, text_elements: [] }],
111
+ },
112
+ { timeoutMs: runtime.requestTimeoutMs },
113
+ );
114
+ return { steered: true, message: "Sent steer message to Codex." };
115
+ }
116
+
117
+ export async function setCodexConversationModel(params: {
118
+ sessionFile: string;
119
+ model: string;
120
+ pluginConfig?: unknown;
121
+ agentDir?: string;
122
+ config?: CodexAppServerBindingLookup["config"];
123
+ }): Promise<string> {
124
+ const model = params.model.trim();
125
+ if (!model) {
126
+ return "Usage: /codex model <model>";
127
+ }
128
+ const lookup = buildBindingLookup(params);
129
+ const binding = await requireThreadBinding(params.sessionFile, lookup);
130
+ const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
131
+ const response = await resumeThreadWithOverrides({
132
+ pluginConfig: params.pluginConfig,
133
+ threadId: binding.threadId,
134
+ authProfileId: binding.authProfileId,
135
+ ...lookup,
136
+ model,
137
+ });
138
+ await writeCodexAppServerBinding(
139
+ params.sessionFile,
140
+ {
141
+ ...binding,
142
+ cwd: response.thread.cwd ?? binding.cwd,
143
+ model: response.model ?? model,
144
+ modelProvider: response.modelProvider ?? binding.modelProvider,
145
+ approvalPolicy: binding.approvalPolicy,
146
+ sandbox: binding.sandbox,
147
+ serviceTier: binding.serviceTier ?? runtime.serviceTier,
148
+ },
149
+ lookup,
150
+ );
151
+ return `Codex model set to ${formatCodexDisplayText(response.model ?? model)}.`;
152
+ }
153
+
154
+ export async function setCodexConversationFastMode(params: {
155
+ sessionFile: string;
156
+ enabled?: boolean;
157
+ pluginConfig?: unknown;
158
+ agentDir?: string;
159
+ config?: CodexAppServerBindingLookup["config"];
160
+ }): Promise<string> {
161
+ const lookup = buildBindingLookup(params);
162
+ const binding = await requireThreadBinding(params.sessionFile, lookup);
163
+ if (params.enabled == null) {
164
+ return `Codex fast mode: ${isCodexFastServiceTier(binding.serviceTier) ? "on" : "off"}.`;
165
+ }
166
+ const serviceTier: CodexServiceTier = params.enabled ? "priority" : "flex";
167
+ // Fast mode is sent on each later turn; do not require Codex to accept an
168
+ // immediate thread/resume control request just to persist the preference.
169
+ await writeCodexAppServerBinding(
170
+ params.sessionFile,
171
+ {
172
+ ...binding,
173
+ serviceTier,
174
+ },
175
+ lookup,
176
+ );
177
+ return `Codex fast mode ${params.enabled ? "enabled" : "disabled"}.`;
178
+ }
179
+
180
+ export async function setCodexConversationPermissions(params: {
181
+ sessionFile: string;
182
+ mode?: PermissionsMode;
183
+ pluginConfig?: unknown;
184
+ agentDir?: string;
185
+ config?: CodexAppServerBindingLookup["config"];
186
+ }): Promise<string> {
187
+ const lookup = buildBindingLookup(params);
188
+ const binding = await requireThreadBinding(params.sessionFile, lookup);
189
+ if (!params.mode) {
190
+ return `Codex permissions: ${formatPermissionsMode(binding)}.`;
191
+ }
192
+ const policy = permissionsForMode(params.mode);
193
+ // Native bound turns pass these settings at turn/start time, so this command
194
+ // can update the local binding even when app-server resume overrides fail.
195
+ await writeCodexAppServerBinding(
196
+ params.sessionFile,
197
+ {
198
+ ...binding,
199
+ approvalPolicy: policy.approvalPolicy,
200
+ sandbox: policy.sandbox,
201
+ },
202
+ lookup,
203
+ );
204
+ return `Codex permissions set to ${params.mode === "yolo" ? "full access" : "default"}.`;
205
+ }
206
+
207
+ export function parseCodexFastModeArg(arg: string | undefined): boolean | undefined {
208
+ const normalized = arg?.trim().toLowerCase();
209
+ if (!normalized || normalized === "status") {
210
+ return undefined;
211
+ }
212
+ if (normalized === "on" || normalized === "true" || normalized === "fast") {
213
+ return true;
214
+ }
215
+ if (normalized === "off" || normalized === "false" || normalized === "flex") {
216
+ return false;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ export function parseCodexPermissionsModeArg(arg: string | undefined): PermissionsMode | undefined {
222
+ const normalized = arg?.trim().toLowerCase();
223
+ if (!normalized || normalized === "status") {
224
+ return undefined;
225
+ }
226
+ if (normalized === "yolo" || normalized === "full" || normalized === "full-access") {
227
+ return "yolo";
228
+ }
229
+ if (normalized === "default" || normalized === "guardian") {
230
+ return "default";
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ export function formatPermissionsMode(binding: {
236
+ approvalPolicy?: CodexAppServerApprovalPolicy;
237
+ sandbox?: CodexAppServerSandboxMode;
238
+ }): string {
239
+ return binding.approvalPolicy === "never" && binding.sandbox === "danger-full-access"
240
+ ? "full access"
241
+ : "default";
242
+ }
243
+
244
+ async function requireThreadBinding(sessionFile: string, lookup: CodexAppServerBindingLookup = {}) {
245
+ const binding = await readCodexAppServerBinding(sessionFile, lookup);
246
+ if (!binding?.threadId) {
247
+ throw new Error("No Codex thread is attached to this Klaw session yet.");
248
+ }
249
+ return binding;
250
+ }
251
+
252
+ async function resumeThreadWithOverrides(params: {
253
+ pluginConfig?: unknown;
254
+ threadId: string;
255
+ authProfileId?: string;
256
+ agentDir?: string;
257
+ config?: CodexAppServerBindingLookup["config"];
258
+ model?: string;
259
+ approvalPolicy?: CodexAppServerApprovalPolicy;
260
+ sandbox?: CodexAppServerSandboxMode;
261
+ serviceTier?: CodexServiceTier;
262
+ }): Promise<CodexThreadResumeResponse> {
263
+ const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
264
+ const client = await getSharedCodexAppServerClient({
265
+ startOptions: runtime.start,
266
+ timeoutMs: runtime.requestTimeoutMs,
267
+ authProfileId: params.authProfileId,
268
+ ...buildBindingLookup(params),
269
+ });
270
+ return await client.request(
271
+ CODEX_CONTROL_METHODS.resumeThread,
272
+ {
273
+ threadId: params.threadId,
274
+ ...(params.model ? { model: params.model } : {}),
275
+ approvalPolicy: params.approvalPolicy ?? runtime.approvalPolicy,
276
+ sandbox: params.sandbox ?? runtime.sandbox,
277
+ approvalsReviewer: runtime.approvalsReviewer,
278
+ ...(params.serviceTier ? { serviceTier: params.serviceTier } : {}),
279
+ persistExtendedHistory: true,
280
+ },
281
+ { timeoutMs: runtime.requestTimeoutMs },
282
+ );
283
+ }
284
+
285
+ function buildBindingLookup(params: {
286
+ agentDir?: string;
287
+ config?: CodexAppServerBindingLookup["config"];
288
+ }): CodexAppServerBindingLookup {
289
+ const agentDir = params.agentDir?.trim();
290
+ return {
291
+ ...(agentDir ? { agentDir } : {}),
292
+ ...(params.config ? { config: params.config } : {}),
293
+ };
294
+ }
295
+
296
+ function permissionsForMode(mode: PermissionsMode): {
297
+ approvalPolicy: CodexAppServerApprovalPolicy;
298
+ sandbox: CodexAppServerSandboxMode;
299
+ } {
300
+ return mode === "yolo"
301
+ ? { approvalPolicy: "never", sandbox: "danger-full-access" }
302
+ : { approvalPolicy: "on-request", sandbox: "workspace-write" };
303
+ }
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
3
+
4
+ describe("codex conversation turn collector", () => {
5
+ it("collects streamed assistant deltas for the active turn", async () => {
6
+ const collector = createCodexConversationTurnCollector("thread-1");
7
+ collector.setTurnId("turn-1");
8
+ const completion = collector.wait({ timeoutMs: 1_000 });
9
+
10
+ collector.handleNotification({
11
+ method: "item/agentMessage/delta",
12
+ params: { threadId: "thread-1", turnId: "turn-1", itemId: "item-1", delta: "hello " },
13
+ });
14
+ collector.handleNotification({
15
+ method: "item/agentMessage/delta",
16
+ params: { threadId: "thread-1", turnId: "turn-1", itemId: "item-1", delta: "world" },
17
+ });
18
+ collector.handleNotification({
19
+ method: "turn/completed",
20
+ params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } },
21
+ });
22
+
23
+ await expect(completion).resolves.toEqual({ replyText: "hello world" });
24
+ });
25
+
26
+ it("buffers pre-start notifications and replays only the selected turn", async () => {
27
+ const collector = createCodexConversationTurnCollector("thread-1");
28
+
29
+ collector.handleNotification({
30
+ method: "turn/completed",
31
+ params: {
32
+ threadId: "thread-1",
33
+ turn: {
34
+ id: "turn-stale",
35
+ status: "completed",
36
+ items: [{ type: "agentMessage", id: "wrong", text: "stale answer" }],
37
+ },
38
+ },
39
+ });
40
+ collector.handleNotification({
41
+ method: "item/agentMessage/delta",
42
+ params: { threadId: "thread-1", turnId: "turn-1", itemId: "right", delta: "fresh " },
43
+ });
44
+ collector.handleNotification({
45
+ method: "turn/completed",
46
+ params: {
47
+ threadId: "thread-1",
48
+ turn: {
49
+ id: "turn-1",
50
+ status: "completed",
51
+ items: [{ type: "agentMessage", id: "right", text: "fresh answer" }],
52
+ },
53
+ },
54
+ });
55
+
56
+ collector.setTurnId("turn-1");
57
+
58
+ await expect(collector.wait({ timeoutMs: 1_000 })).resolves.toEqual({
59
+ replyText: "fresh answer",
60
+ });
61
+ });
62
+
63
+ it("uses completed agent message items when deltas are absent", async () => {
64
+ const collector = createCodexConversationTurnCollector("thread-1");
65
+ collector.setTurnId("turn-1");
66
+ const completion = collector.wait({ timeoutMs: 1_000 });
67
+
68
+ collector.handleNotification({
69
+ method: "item/completed",
70
+ params: {
71
+ threadId: "thread-1",
72
+ turnId: "turn-1",
73
+ item: { type: "agentMessage", id: "item-1", text: "final answer" },
74
+ },
75
+ });
76
+ collector.handleNotification({
77
+ method: "turn/completed",
78
+ params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } },
79
+ });
80
+
81
+ await expect(completion).resolves.toEqual({ replyText: "final answer" });
82
+ });
83
+
84
+ it("ignores notifications for other threads or turns", async () => {
85
+ const collector = createCodexConversationTurnCollector("thread-1");
86
+ collector.setTurnId("turn-1");
87
+ const completion = collector.wait({ timeoutMs: 1_000 });
88
+
89
+ collector.handleNotification({
90
+ method: "item/agentMessage/delta",
91
+ params: { threadId: "thread-2", turnId: "turn-1", itemId: "wrong", delta: "wrong" },
92
+ });
93
+ collector.handleNotification({
94
+ method: "item/agentMessage/delta",
95
+ params: { threadId: "thread-1", turnId: "turn-2", itemId: "wrong", delta: "wrong" },
96
+ });
97
+ collector.handleNotification({
98
+ method: "turn/completed",
99
+ params: {
100
+ threadId: "thread-1",
101
+ turn: {
102
+ id: "turn-1",
103
+ status: "completed",
104
+ items: [{ type: "agentMessage", id: "item-1", text: "right" }],
105
+ },
106
+ },
107
+ });
108
+
109
+ await expect(completion).resolves.toEqual({ replyText: "right" });
110
+ });
111
+
112
+ it("ignores unscoped deltas once the active turn is known", async () => {
113
+ const collector = createCodexConversationTurnCollector("thread-1");
114
+ collector.setTurnId("turn-1");
115
+ const completion = collector.wait({ timeoutMs: 1_000 });
116
+
117
+ collector.handleNotification({
118
+ method: "item/agentMessage/delta",
119
+ params: { threadId: "thread-1", itemId: "wrong", delta: "wrong" },
120
+ });
121
+ collector.handleNotification({
122
+ method: "item/agentMessage/delta",
123
+ params: { threadId: "thread-1", turnId: "turn-1", itemId: "right", delta: "right" },
124
+ });
125
+ collector.handleNotification({
126
+ method: "turn/completed",
127
+ params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } },
128
+ });
129
+
130
+ await expect(completion).resolves.toEqual({ replyText: "right" });
131
+ });
132
+
133
+ it("does not complete from unscoped turn completion once the active turn is known", async () => {
134
+ const collector = createCodexConversationTurnCollector("thread-1");
135
+ collector.setTurnId("turn-1");
136
+ const completion = collector.wait({ timeoutMs: 1_000 });
137
+
138
+ collector.handleNotification({
139
+ method: "turn/completed",
140
+ params: {
141
+ threadId: "thread-1",
142
+ turn: {
143
+ status: "completed",
144
+ items: [{ type: "agentMessage", id: "wrong", text: "wrong" }],
145
+ },
146
+ },
147
+ });
148
+ collector.handleNotification({
149
+ method: "turn/completed",
150
+ params: {
151
+ threadId: "thread-1",
152
+ turn: {
153
+ id: "turn-1",
154
+ status: "completed",
155
+ items: [{ type: "agentMessage", id: "right", text: "right" }],
156
+ },
157
+ },
158
+ });
159
+
160
+ await expect(completion).resolves.toEqual({ replyText: "right" });
161
+ });
162
+
163
+ it("rejects failed turns with the app-server error message", async () => {
164
+ const collector = createCodexConversationTurnCollector("thread-1");
165
+ collector.setTurnId("turn-1");
166
+ const completion = collector.wait({ timeoutMs: 1_000 });
167
+
168
+ collector.handleNotification({
169
+ method: "turn/completed",
170
+ params: {
171
+ threadId: "thread-1",
172
+ turn: { id: "turn-1", status: "failed", error: { message: "model exploded" }, items: [] },
173
+ },
174
+ });
175
+
176
+ await expect(completion).rejects.toThrow("model exploded");
177
+ });
178
+
179
+ it("times out when the app-server never completes the turn", async () => {
180
+ vi.useFakeTimers();
181
+ try {
182
+ const collector = createCodexConversationTurnCollector("thread-1");
183
+ const completion = collector.wait({ timeoutMs: 100 });
184
+ const assertion = expect(completion).rejects.toThrow("codex app-server bound turn timed out");
185
+ await vi.advanceTimersByTimeAsync(100);
186
+ await assertion;
187
+ } finally {
188
+ vi.useRealTimers();
189
+ }
190
+ });
191
+ });
@@ -0,0 +1,186 @@
1
+ import {
2
+ isJsonObject,
3
+ type CodexServerNotification,
4
+ type JsonObject,
5
+ } from "./app-server/protocol.js";
6
+
7
+ const MAX_PENDING_NOTIFICATIONS_PER_TURN = 100;
8
+
9
+ export function createCodexConversationTurnCollector(threadId: string) {
10
+ let turnId: string | undefined;
11
+ let completed = false;
12
+ let failedError: string | undefined;
13
+ let timeout: ReturnType<typeof setTimeout> | undefined;
14
+ const assistantTextByItem = new Map<string, string>();
15
+ const assistantOrder: string[] = [];
16
+ const pendingNotificationsByTurnId = new Map<string, CodexServerNotification[]>();
17
+ let resolveCompletion: ((value: { replyText: string }) => void) | undefined;
18
+ let rejectCompletion: ((error: Error) => void) | undefined;
19
+
20
+ const rememberItem = (itemId: string) => {
21
+ if (!assistantOrder.includes(itemId)) {
22
+ assistantOrder.push(itemId);
23
+ }
24
+ };
25
+ const collectReplyText = (): string => {
26
+ const texts = assistantOrder
27
+ .map((itemId) => assistantTextByItem.get(itemId)?.trim())
28
+ .filter((text): text is string => Boolean(text));
29
+ return texts.at(-1) ?? "";
30
+ };
31
+ const clearWaitState = () => {
32
+ if (timeout) {
33
+ clearTimeout(timeout);
34
+ timeout = undefined;
35
+ }
36
+ resolveCompletion = undefined;
37
+ rejectCompletion = undefined;
38
+ };
39
+ const finish = () => {
40
+ if (completed) {
41
+ return;
42
+ }
43
+ completed = true;
44
+ if (failedError) {
45
+ rejectCompletion?.(new Error(failedError));
46
+ } else {
47
+ resolveCompletion?.({ replyText: collectReplyText() });
48
+ }
49
+ clearWaitState();
50
+ };
51
+
52
+ const handleNotification = (notification: CodexServerNotification) => {
53
+ const params = isJsonObject(notification.params) ? notification.params : undefined;
54
+ if (!params || readString(params, "threadId") !== threadId) {
55
+ return;
56
+ }
57
+ if (!turnId) {
58
+ const pendingTurnId = readNotificationTurnId(params);
59
+ if (pendingTurnId) {
60
+ const pending = pendingNotificationsByTurnId.get(pendingTurnId) ?? [];
61
+ if (pending.length < MAX_PENDING_NOTIFICATIONS_PER_TURN) {
62
+ pending.push(notification);
63
+ pendingNotificationsByTurnId.set(pendingTurnId, pending);
64
+ }
65
+ }
66
+ return;
67
+ }
68
+ if (!isNotificationForTurn(params, threadId, turnId)) {
69
+ return;
70
+ }
71
+ if (notification.method === "item/agentMessage/delta") {
72
+ const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "assistant";
73
+ const delta = readTextString(params, "delta");
74
+ if (!delta) {
75
+ return;
76
+ }
77
+ rememberItem(itemId);
78
+ assistantTextByItem.set(itemId, `${assistantTextByItem.get(itemId) ?? ""}${delta}`);
79
+ return;
80
+ }
81
+ if (notification.method === "item/completed") {
82
+ const item = isJsonObject(params.item) ? params.item : undefined;
83
+ if (item?.type === "agentMessage") {
84
+ const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant";
85
+ const text = readTextString(item, "text");
86
+ if (text) {
87
+ rememberItem(itemId);
88
+ assistantTextByItem.set(itemId, text);
89
+ }
90
+ }
91
+ return;
92
+ }
93
+ if (notification.method === "turn/completed") {
94
+ const turn = isJsonObject(params.turn) ? params.turn : undefined;
95
+ const status = readString(turn, "status");
96
+ if (status === "failed") {
97
+ failedError =
98
+ readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed";
99
+ }
100
+ const items = Array.isArray(turn?.items) ? turn.items : [];
101
+ for (const item of items) {
102
+ if (!isJsonObject(item) || item.type !== "agentMessage") {
103
+ continue;
104
+ }
105
+ const itemId = readString(item, "id") ?? `assistant-${assistantOrder.length + 1}`;
106
+ const text = readTextString(item, "text");
107
+ if (text) {
108
+ rememberItem(itemId);
109
+ assistantTextByItem.set(itemId, text);
110
+ }
111
+ }
112
+ finish();
113
+ }
114
+ };
115
+
116
+ return {
117
+ setTurnId(nextTurnId: string) {
118
+ turnId = nextTurnId;
119
+ const pending = pendingNotificationsByTurnId.get(nextTurnId) ?? [];
120
+ pendingNotificationsByTurnId.clear();
121
+ for (const notification of pending) {
122
+ handleNotification(notification);
123
+ }
124
+ },
125
+ handleNotification,
126
+ wait(params: { timeoutMs: number }): Promise<{ replyText: string }> {
127
+ if (completed) {
128
+ return failedError
129
+ ? Promise.reject(new Error(failedError))
130
+ : Promise.resolve({ replyText: collectReplyText() });
131
+ }
132
+ return new Promise<{ replyText: string }>((resolve, reject) => {
133
+ resolveCompletion = resolve;
134
+ rejectCompletion = reject;
135
+ timeout = setTimeout(
136
+ () => {
137
+ completed = true;
138
+ reject(new Error("codex app-server bound turn timed out"));
139
+ clearWaitState();
140
+ },
141
+ Math.max(100, params.timeoutMs),
142
+ );
143
+ timeout.unref?.();
144
+ });
145
+ },
146
+ };
147
+ }
148
+
149
+ function isNotificationForTurn(
150
+ params: JsonObject,
151
+ threadId: string,
152
+ turnId: string | undefined,
153
+ ): boolean {
154
+ if (readString(params, "threadId") !== threadId) {
155
+ return false;
156
+ }
157
+ if (!turnId) {
158
+ return true;
159
+ }
160
+ const directTurnId = readString(params, "turnId");
161
+ if (directTurnId) {
162
+ return directTurnId === turnId;
163
+ }
164
+ const turn = isJsonObject(params.turn) ? params.turn : undefined;
165
+ return readString(turn, "id") === turnId;
166
+ }
167
+
168
+ function readNotificationTurnId(params: JsonObject): string | undefined {
169
+ return readString(params, "turnId") ?? readString(readRecord(params.turn), "id");
170
+ }
171
+
172
+ function readRecord(value: unknown): Record<string, unknown> | undefined {
173
+ return value && typeof value === "object" && !Array.isArray(value)
174
+ ? (value as Record<string, unknown>)
175
+ : undefined;
176
+ }
177
+
178
+ function readString(record: Record<string, unknown> | JsonObject | undefined, key: string) {
179
+ const value = record?.[key];
180
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
181
+ }
182
+
183
+ function readTextString(record: Record<string, unknown> | JsonObject | undefined, key: string) {
184
+ const value = record?.[key];
185
+ return typeof value === "string" && value.length > 0 ? value : undefined;
186
+ }