@linzumi/cli 0.0.1-beta → 0.0.2-beta
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.
- package/README.md +78 -94
- package/bin/linzumi.js +16 -8
- package/package.json +4 -2
- package/src/authCache.ts +157 -0
- package/src/authResolution.ts +75 -0
- package/src/channelSession.ts +3248 -0
- package/src/channelSessionSupport.ts +255 -0
- package/src/codexAppServer.ts +380 -0
- package/src/codexOutput.ts +846 -0
- package/src/index.ts +354 -0
- package/src/json.ts +49 -0
- package/src/kandanQueue.ts +102 -0
- package/src/oauth.ts +294 -0
- package/src/phoenix.ts +335 -0
- package/src/protocol.ts +211 -0
- package/src/runner.ts +524 -0
- package/src/runnerConsoleReporter.ts +142 -0
- package/src/runnerLogger.ts +50 -0
- package/src/cli.js +0 -240
|
@@ -0,0 +1,3248 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-24
|
|
3
|
+
Spec: plans/2026-04-24-local-codex-channel-session-module-spec.md
|
|
4
|
+
Relationship: Owns one channel-bound local Codex session: Kandan thread
|
|
5
|
+
binding, sender queueing, Codex turn lifecycle, typing heartbeat, queued
|
|
6
|
+
interrupt handling, and Codex output forwarding.
|
|
7
|
+
|
|
8
|
+
- Date: 2026-04-24
|
|
9
|
+
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
10
|
+
Relationship: Keeps channel event dedupe bounded and represents Codex turn
|
|
11
|
+
lifecycle as an explicit state machine so queued-message interrupts cannot
|
|
12
|
+
race in-flight turn starts, malformed stored Codex thread ids can rebound,
|
|
13
|
+
and non-recoverable delivery failures become terminal failed input states.
|
|
14
|
+
|
|
15
|
+
- Date: 2026-04-25
|
|
16
|
+
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
17
|
+
Relationship: Makes bare `processing` impossible to publish from the runner
|
|
18
|
+
by requiring each processing message state to carry the specific active
|
|
19
|
+
Codex work reason at the channel-session boundary.
|
|
20
|
+
|
|
21
|
+
- Date: 2026-04-25
|
|
22
|
+
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
23
|
+
Relationship: Reduces raw Codex notifications and thread snapshots into
|
|
24
|
+
logical transcript units before Kandan projection, so TUI input projection
|
|
25
|
+
failures and ambiguous assistant-output reconciliation fail as state-machine
|
|
26
|
+
errors instead of creating source-less or duplicate-looking transcript rows,
|
|
27
|
+
and so live reasoning, command-output, terminal-interaction, search, abort,
|
|
28
|
+
live file-change, and completed tool-call observations update Kandan using
|
|
29
|
+
existing Codex output kinds.
|
|
30
|
+
|
|
31
|
+
- Date: 2026-04-25
|
|
32
|
+
Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
|
|
33
|
+
Relationship: Surfaces non-auto-accepted Codex app-server approval requests
|
|
34
|
+
as Kandan message state, then resolves the blocked app-server request only
|
|
35
|
+
after a scoped Kandan approval control arrives.
|
|
36
|
+
*/
|
|
37
|
+
import {
|
|
38
|
+
availabilityMessage,
|
|
39
|
+
detectCodexVersion,
|
|
40
|
+
identityFromAccessToken,
|
|
41
|
+
isCodexAuthoredEvent,
|
|
42
|
+
localRunnerPayload,
|
|
43
|
+
parseKandanChatEvent,
|
|
44
|
+
senderAllowed,
|
|
45
|
+
type KandanChatEvent,
|
|
46
|
+
type RunnerIdentity,
|
|
47
|
+
type RunnerPayloadContext,
|
|
48
|
+
} from "./channelSessionSupport";
|
|
49
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
50
|
+
import { type CodexAppServerClient } from "./codexAppServer";
|
|
51
|
+
import {
|
|
52
|
+
codexAssistantDeltaFromNotification,
|
|
53
|
+
codexAssistantStructuredMessage,
|
|
54
|
+
codexCommandExecutionStructuredMessage,
|
|
55
|
+
codexCommandOutputDeltaFromNotification,
|
|
56
|
+
codexFileChangeDeltaFromNotification,
|
|
57
|
+
codexFileChangeStructuredMessage,
|
|
58
|
+
codexOutputMessagesForTurn,
|
|
59
|
+
codexReasoningDeltaFromNotification,
|
|
60
|
+
codexReasoningStructuredMessage,
|
|
61
|
+
codexTerminalInputFromNotification,
|
|
62
|
+
codexWebSearchProgressFromNotification,
|
|
63
|
+
codexWebSearchStructuredMessage,
|
|
64
|
+
webSearchProgressBody,
|
|
65
|
+
codexUserInputMessageFromNotification,
|
|
66
|
+
codexUserInputMessagesForTurn,
|
|
67
|
+
type CodexUserInputMessage,
|
|
68
|
+
} from "./codexOutput";
|
|
69
|
+
import { arrayValue, integerValue, objectValue, stringValue } from "./json";
|
|
70
|
+
import {
|
|
71
|
+
codexInputForQueuedKandanMessage,
|
|
72
|
+
interruptQueuedMessages,
|
|
73
|
+
type QueuedKandanMessage,
|
|
74
|
+
} from "./kandanQueue";
|
|
75
|
+
import { type PhoenixClient } from "./phoenix";
|
|
76
|
+
import {
|
|
77
|
+
type JsonObject,
|
|
78
|
+
type JsonRpcRequest,
|
|
79
|
+
type JsonRpcResponse,
|
|
80
|
+
type KandanChannelSessionOptions,
|
|
81
|
+
type KandanControl,
|
|
82
|
+
isJsonObject,
|
|
83
|
+
} from "./protocol";
|
|
84
|
+
import { type RunnerLogger } from "./runnerLogger";
|
|
85
|
+
|
|
86
|
+
export type ChannelSessionRuntime = {
|
|
87
|
+
readonly handleCodexNotification: (
|
|
88
|
+
method: string,
|
|
89
|
+
params: JsonObject,
|
|
90
|
+
) => void;
|
|
91
|
+
readonly handleControl: (control: KandanControl) => Promise<JsonObject | undefined>;
|
|
92
|
+
readonly handleKandanReconnect: () => Promise<void>;
|
|
93
|
+
readonly currentCodexThreadId: () => string | undefined;
|
|
94
|
+
readonly currentKandanThreadId: () => string | undefined;
|
|
95
|
+
readonly close: () => Promise<void>;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type ChannelSessionRunnerOptions = {
|
|
99
|
+
readonly token: string;
|
|
100
|
+
readonly runnerId: string;
|
|
101
|
+
readonly cwd: string;
|
|
102
|
+
readonly codexBin: string;
|
|
103
|
+
readonly fast?: boolean | undefined;
|
|
104
|
+
readonly launchTui?: boolean | undefined;
|
|
105
|
+
readonly channelSession: KandanChannelSessionOptions;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ChannelSessionContext = {
|
|
109
|
+
readonly kandan: PhoenixClient;
|
|
110
|
+
readonly codex: CodexAppServerClient;
|
|
111
|
+
readonly topic: string;
|
|
112
|
+
readonly instanceId: string;
|
|
113
|
+
readonly options: ChannelSessionRunnerOptions;
|
|
114
|
+
readonly log: RunnerLogger;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type ChannelSessionState = {
|
|
118
|
+
rootSeq: number | undefined;
|
|
119
|
+
kandanThreadId: string | undefined;
|
|
120
|
+
codexThreadId: string | undefined;
|
|
121
|
+
turn: TurnState;
|
|
122
|
+
closed: boolean;
|
|
123
|
+
minSeq: number;
|
|
124
|
+
queue: QueuedKandanMessage[];
|
|
125
|
+
forwardedTurnIds: string[];
|
|
126
|
+
forwardingTurnIds: string[];
|
|
127
|
+
retryableTurnIds: string[];
|
|
128
|
+
localTuiTurnIds: string[];
|
|
129
|
+
mirroredTuiInputProjections: MirroredTuiInputProjection[];
|
|
130
|
+
pendingTuiInputMirrors: PendingTuiInputMirror[];
|
|
131
|
+
turnReplyTargets: TurnReplyTarget[];
|
|
132
|
+
streamingAssistantOutputs: StreamingAssistantOutput[];
|
|
133
|
+
streamingReasoningOutputs: StreamingReasoningOutput[];
|
|
134
|
+
streamingCommandOutputs: StreamingCommandOutput[];
|
|
135
|
+
streamingFileChangeOutputs: StreamingFileChangeOutput[];
|
|
136
|
+
forwardedTerminalInputKeys: string[];
|
|
137
|
+
webSearchProgressOutputs: WebSearchProgressOutput[];
|
|
138
|
+
pendingApprovalRequests: PendingCodexApprovalRequest[];
|
|
139
|
+
activeProcessingState: ActiveProcessingState | undefined;
|
|
140
|
+
assistantDeltaForwardChain: Promise<void>;
|
|
141
|
+
reasoningDeltaForwardChain: Promise<void>;
|
|
142
|
+
commandOutputForwardChain: Promise<void>;
|
|
143
|
+
fileChangeForwardChain: Promise<void>;
|
|
144
|
+
terminalInputForwardChain: Promise<void>;
|
|
145
|
+
webSearchProgressForwardChain: Promise<void>;
|
|
146
|
+
typingHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
147
|
+
typingHeartbeatInFlight: boolean;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type TurnState =
|
|
151
|
+
| { readonly status: "idle" }
|
|
152
|
+
| { readonly status: "starting"; readonly queuedSeq: number; readonly interruptAfterStart: boolean }
|
|
153
|
+
| { readonly status: "active"; readonly turnId: string; readonly queuedSeq: number }
|
|
154
|
+
| { readonly status: "completing"; readonly turnId: string; readonly queuedSeq: number };
|
|
155
|
+
|
|
156
|
+
const codexTypingHeartbeatMs = 5_000;
|
|
157
|
+
const maxForwardedTurnIds = 64;
|
|
158
|
+
|
|
159
|
+
type StreamingAssistantOutput = {
|
|
160
|
+
readonly itemKey: string;
|
|
161
|
+
readonly turnId: string | undefined;
|
|
162
|
+
readonly seq: number;
|
|
163
|
+
readonly content: string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
type StreamingReasoningOutput = {
|
|
167
|
+
readonly itemKey: string;
|
|
168
|
+
readonly turnId: string | undefined;
|
|
169
|
+
readonly seq: number;
|
|
170
|
+
readonly content: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
type StreamingCommandOutput = {
|
|
174
|
+
readonly itemKey: string;
|
|
175
|
+
readonly turnId: string | undefined;
|
|
176
|
+
readonly seq: number;
|
|
177
|
+
readonly output: string;
|
|
178
|
+
readonly processId: string | undefined;
|
|
179
|
+
readonly stream: string;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
type StreamingFileChangeOutput = {
|
|
183
|
+
readonly itemKey: string;
|
|
184
|
+
readonly turnId: string | undefined;
|
|
185
|
+
readonly seq: number;
|
|
186
|
+
readonly patchText: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
type WebSearchProgressOutput = {
|
|
190
|
+
readonly turnId: string;
|
|
191
|
+
readonly itemKey: string;
|
|
192
|
+
readonly seq: number;
|
|
193
|
+
readonly queries: string[];
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
type MirroredTuiInputProjection = {
|
|
197
|
+
readonly logicalKey: string;
|
|
198
|
+
readonly turnId: string;
|
|
199
|
+
readonly seq: number;
|
|
200
|
+
readonly itemKeys: string[];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
type PendingTuiInputMirror = {
|
|
204
|
+
readonly turnId: string;
|
|
205
|
+
readonly promise: Promise<void>;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
type TurnReplyTarget = {
|
|
209
|
+
readonly turnId: string;
|
|
210
|
+
readonly replyToSeq: number;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
type ActiveProcessingState =
|
|
214
|
+
| {
|
|
215
|
+
readonly seq: number;
|
|
216
|
+
readonly reason: Exclude<LocalCodexProcessingReason, "awaiting approval">;
|
|
217
|
+
}
|
|
218
|
+
| {
|
|
219
|
+
readonly seq: number;
|
|
220
|
+
readonly reason: "awaiting approval";
|
|
221
|
+
readonly approval: CodexApprovalMessageState;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
type PendingCodexApprovalRequest = {
|
|
225
|
+
readonly requestId: string;
|
|
226
|
+
readonly sourceSeq: number;
|
|
227
|
+
readonly turnId: string;
|
|
228
|
+
readonly resolve: (response: JsonObject) => void;
|
|
229
|
+
readonly reject: (error: Error) => void;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
type CodexApprovalMessageState = {
|
|
233
|
+
readonly requestId: string;
|
|
234
|
+
readonly kind: string;
|
|
235
|
+
readonly summary: string;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export async function attachChannelSession(
|
|
239
|
+
args: ChannelSessionContext,
|
|
240
|
+
): Promise<ChannelSessionRuntime> {
|
|
241
|
+
const session = args.options.channelSession;
|
|
242
|
+
const chatTopic = `chat:${session.workspaceSlug}:${session.channelSlug}`;
|
|
243
|
+
const state = initialChannelSessionState(0, session.kandanThreadId);
|
|
244
|
+
const joined = await args.kandan.join(chatTopic, { last_seq: 0 }, {
|
|
245
|
+
rejoinPayload: () => ({ last_seq: state.minSeq }),
|
|
246
|
+
});
|
|
247
|
+
const cursor = integerValue(joined.cursor) ?? 0;
|
|
248
|
+
const runnerIdentity = identityFromAccessToken(args.options.token);
|
|
249
|
+
const codexVersion = detectCodexVersion(args.options.codexBin, args.options.cwd);
|
|
250
|
+
state.minSeq = cursor;
|
|
251
|
+
const payloadContext = {
|
|
252
|
+
runnerIdentity,
|
|
253
|
+
codexVersion,
|
|
254
|
+
};
|
|
255
|
+
const eventBuffer = {
|
|
256
|
+
ready: false,
|
|
257
|
+
pending: [] as KandanChatEvent[],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
args.codex.onRequest?.(request =>
|
|
261
|
+
handleCodexServerRequest(args, state, payloadContext, request)
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
args.kandan.onEvent((topic, event, payload) => {
|
|
265
|
+
if (topic !== chatTopic || event !== "event") {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const parsed = parseKandanChatEvent(payload);
|
|
270
|
+
|
|
271
|
+
if (parsed === undefined) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!eventBuffer.ready) {
|
|
276
|
+
eventBuffer.pending.push(parsed);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
void processKandanChatEvent(args, state, runnerIdentity, parsed, payloadContext);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await bindChannelSession(args, state, payloadContext);
|
|
284
|
+
await processBufferedKandanEvents(args, state, runnerIdentity, payloadContext, eventBuffer.pending);
|
|
285
|
+
eventBuffer.ready = true;
|
|
286
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
handleCodexNotification: (method, params) => {
|
|
290
|
+
const turnId = codexNotificationTurnId(params);
|
|
291
|
+
const threadId = codexNotificationThreadId(params);
|
|
292
|
+
|
|
293
|
+
if (
|
|
294
|
+
threadId !== undefined &&
|
|
295
|
+
state.codexThreadId === threadId
|
|
296
|
+
) {
|
|
297
|
+
const processingReason = processingReasonForCodexNotification(method, params);
|
|
298
|
+
if (turnId !== undefined && processingReason !== undefined) {
|
|
299
|
+
void refreshActiveProcessingState(args, state, turnId, processingReason).catch(error => {
|
|
300
|
+
args.log("kandan.message_state_refresh_failed", {
|
|
301
|
+
turn_id: turnId,
|
|
302
|
+
reason: processingReason,
|
|
303
|
+
message: error instanceof Error ? error.message : String(error),
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
switch (method) {
|
|
309
|
+
case "turn/started":
|
|
310
|
+
if (turnId !== undefined) {
|
|
311
|
+
rememberLocalTuiTurnIfNeeded(args, state, threadId, turnId);
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
case "turn/aborted":
|
|
315
|
+
case "turn/canceled":
|
|
316
|
+
case "turn/cancelled":
|
|
317
|
+
case "turn/failed":
|
|
318
|
+
if (turnId !== undefined) {
|
|
319
|
+
void failActiveCodexTurn(args, state, turnId, abortReason(params), payloadContext)
|
|
320
|
+
.catch(error => {
|
|
321
|
+
args.log("codex.turn_abort_handling_failed", {
|
|
322
|
+
turn_id: turnId,
|
|
323
|
+
message: error instanceof Error ? error.message : String(error),
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
case "turn/completed":
|
|
329
|
+
if (turnId !== undefined) {
|
|
330
|
+
enqueueWebSearchProgressCompletion(args, state, turnId);
|
|
331
|
+
enqueueFileChangeCompletion(args, state, turnId);
|
|
332
|
+
void forwardCompletedCodexTurn(args, state, turnId, payloadContext).catch(error => {
|
|
333
|
+
args.log("codex.turn_forward_failed", {
|
|
334
|
+
turn_id: turnId,
|
|
335
|
+
message: error instanceof Error ? error.message : String(error),
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
case "item/agentMessage/delta":
|
|
341
|
+
enqueueAssistantDelta(args, state, params, payloadContext);
|
|
342
|
+
break;
|
|
343
|
+
case "item/reasoning/textDelta":
|
|
344
|
+
enqueueReasoningDelta(args, state, params, payloadContext);
|
|
345
|
+
break;
|
|
346
|
+
case "item/commandExecution/outputDelta":
|
|
347
|
+
case "command/exec/outputDelta":
|
|
348
|
+
enqueueCommandOutputDelta(args, state, params, payloadContext);
|
|
349
|
+
break;
|
|
350
|
+
case "item/fileChange/outputDelta":
|
|
351
|
+
enqueueFileChangeDelta(args, state, params, payloadContext);
|
|
352
|
+
break;
|
|
353
|
+
case "item/commandExecution/terminalInteraction":
|
|
354
|
+
enqueueTerminalInput(args, state, params, payloadContext);
|
|
355
|
+
break;
|
|
356
|
+
case "item/started":
|
|
357
|
+
enqueueWebSearchProgress(args, state, params, payloadContext);
|
|
358
|
+
break;
|
|
359
|
+
case "item/completed":
|
|
360
|
+
enqueueWebSearchProgress(args, state, params, payloadContext);
|
|
361
|
+
if (turnId !== undefined) {
|
|
362
|
+
const promise = mirrorLocalTuiInputFromNotification(
|
|
363
|
+
args,
|
|
364
|
+
state,
|
|
365
|
+
turnId,
|
|
366
|
+
params,
|
|
367
|
+
payloadContext,
|
|
368
|
+
);
|
|
369
|
+
rememberPendingTuiInputMirror(state, turnId, promise);
|
|
370
|
+
void promise
|
|
371
|
+
.catch(error => {
|
|
372
|
+
args.log("codex.tui_input_mirror_failed", {
|
|
373
|
+
turn_id: turnId,
|
|
374
|
+
message: error instanceof Error ? error.message : String(error),
|
|
375
|
+
});
|
|
376
|
+
})
|
|
377
|
+
.finally(() => forgetPendingTuiInputMirror(state, turnId));
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
handleControl: control => handleChannelSessionControl(args, state, payloadContext, control),
|
|
384
|
+
currentCodexThreadId: () => state.codexThreadId,
|
|
385
|
+
currentKandanThreadId: () => state.kandanThreadId,
|
|
386
|
+
handleKandanReconnect: async () => {
|
|
387
|
+
if (state.closed) {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
args.log("kandan.reconnected", {
|
|
392
|
+
kandan_thread_id: state.kandanThreadId ?? null,
|
|
393
|
+
codex_thread_id: state.codexThreadId ?? null,
|
|
394
|
+
min_seq: state.minSeq,
|
|
395
|
+
});
|
|
396
|
+
await bindCurrentCodexThread(args, state);
|
|
397
|
+
if (state.kandanThreadId !== undefined && state.turn.status !== "idle") {
|
|
398
|
+
startCodexTypingHeartbeat(args, state, state.kandanThreadId);
|
|
399
|
+
}
|
|
400
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
401
|
+
},
|
|
402
|
+
close: async () => {
|
|
403
|
+
state.closed = true;
|
|
404
|
+
rejectPendingApprovalRequests(state, new Error("runner closed"));
|
|
405
|
+
await stopCodexTyping(args, state);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function bindCurrentCodexThread(
|
|
411
|
+
args: ChannelSessionContext,
|
|
412
|
+
state: ChannelSessionState,
|
|
413
|
+
): Promise<void> {
|
|
414
|
+
if (state.codexThreadId === undefined) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const session = args.options.channelSession;
|
|
419
|
+
await pushOk(args.kandan, args.topic, "session:bind", {
|
|
420
|
+
workspace: session.workspaceSlug,
|
|
421
|
+
channel: session.channelSlug,
|
|
422
|
+
thread_id: state.kandanThreadId ?? null,
|
|
423
|
+
codex_thread_id: state.codexThreadId,
|
|
424
|
+
instance_id: args.instanceId,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function initialChannelSessionState(
|
|
429
|
+
cursor: number,
|
|
430
|
+
kandanThreadId: string | undefined,
|
|
431
|
+
): ChannelSessionState {
|
|
432
|
+
return {
|
|
433
|
+
rootSeq: undefined,
|
|
434
|
+
kandanThreadId,
|
|
435
|
+
codexThreadId: undefined,
|
|
436
|
+
turn: { status: "idle" },
|
|
437
|
+
closed: false,
|
|
438
|
+
minSeq: cursor,
|
|
439
|
+
queue: [],
|
|
440
|
+
forwardedTurnIds: [],
|
|
441
|
+
forwardingTurnIds: [],
|
|
442
|
+
retryableTurnIds: [],
|
|
443
|
+
localTuiTurnIds: [],
|
|
444
|
+
mirroredTuiInputProjections: [],
|
|
445
|
+
pendingTuiInputMirrors: [],
|
|
446
|
+
turnReplyTargets: [],
|
|
447
|
+
streamingAssistantOutputs: [],
|
|
448
|
+
streamingReasoningOutputs: [],
|
|
449
|
+
streamingCommandOutputs: [],
|
|
450
|
+
streamingFileChangeOutputs: [],
|
|
451
|
+
forwardedTerminalInputKeys: [],
|
|
452
|
+
webSearchProgressOutputs: [],
|
|
453
|
+
pendingApprovalRequests: [],
|
|
454
|
+
activeProcessingState: undefined,
|
|
455
|
+
assistantDeltaForwardChain: Promise.resolve(),
|
|
456
|
+
reasoningDeltaForwardChain: Promise.resolve(),
|
|
457
|
+
commandOutputForwardChain: Promise.resolve(),
|
|
458
|
+
fileChangeForwardChain: Promise.resolve(),
|
|
459
|
+
terminalInputForwardChain: Promise.resolve(),
|
|
460
|
+
webSearchProgressForwardChain: Promise.resolve(),
|
|
461
|
+
typingHeartbeat: undefined,
|
|
462
|
+
typingHeartbeatInFlight: false,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function bindChannelSession(
|
|
467
|
+
args: ChannelSessionContext,
|
|
468
|
+
state: ChannelSessionState,
|
|
469
|
+
payloadContext: RunnerPayloadContext,
|
|
470
|
+
): Promise<void> {
|
|
471
|
+
const session = args.options.channelSession;
|
|
472
|
+
const codexVersion = payloadContext.codexVersion;
|
|
473
|
+
|
|
474
|
+
if (state.kandanThreadId === undefined) {
|
|
475
|
+
state.codexThreadId = await startCodexThread(args.codex, args.options);
|
|
476
|
+
const reply = await pushOk(args.kandan, args.topic, "session:announce", {
|
|
477
|
+
workspace: session.workspaceSlug,
|
|
478
|
+
channel: session.channelSlug,
|
|
479
|
+
body: availabilityMessage(args.options, codexVersion, state.codexThreadId),
|
|
480
|
+
payload: localRunnerPayload(
|
|
481
|
+
args.options,
|
|
482
|
+
args.instanceId,
|
|
483
|
+
"availability",
|
|
484
|
+
state.codexThreadId,
|
|
485
|
+
payloadContext,
|
|
486
|
+
),
|
|
487
|
+
client_message_id: `local-codex-root-${args.instanceId}`,
|
|
488
|
+
});
|
|
489
|
+
state.rootSeq = integerValue(reply.seq);
|
|
490
|
+
if (state.rootSeq !== undefined) {
|
|
491
|
+
state.minSeq = Math.max(state.minSeq, state.rootSeq);
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
const resolved = await pushOk(args.kandan, args.topic, "session:resolve_thread_session", {
|
|
495
|
+
workspace: session.workspaceSlug,
|
|
496
|
+
channel: session.channelSlug,
|
|
497
|
+
thread_id: state.kandanThreadId,
|
|
498
|
+
});
|
|
499
|
+
state.rootSeq = integerValue(resolved.seq);
|
|
500
|
+
state.codexThreadId = stringValue(resolved.codex_thread_id);
|
|
501
|
+
|
|
502
|
+
if (state.codexThreadId === undefined) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
"Kandan thread root metadata did not include a Codex thread id",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
509
|
+
workspace: session.workspaceSlug,
|
|
510
|
+
channel: session.channelSlug,
|
|
511
|
+
thread_id: state.kandanThreadId,
|
|
512
|
+
body: availabilityMessage(args.options, codexVersion, state.codexThreadId),
|
|
513
|
+
payload: localRunnerPayload(
|
|
514
|
+
args.options,
|
|
515
|
+
args.instanceId,
|
|
516
|
+
"availability",
|
|
517
|
+
state.codexThreadId,
|
|
518
|
+
payloadContext,
|
|
519
|
+
),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function handleChannelSessionControl(
|
|
525
|
+
args: ChannelSessionContext,
|
|
526
|
+
state: ChannelSessionState,
|
|
527
|
+
payloadContext: RunnerPayloadContext,
|
|
528
|
+
control: KandanControl,
|
|
529
|
+
): Promise<JsonObject | undefined> {
|
|
530
|
+
if (control.type === "resolve_codex_approval_request") {
|
|
531
|
+
return resolvePendingCodexApprovalRequest(args, state, control);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (control.type !== "interrupt_queued_messages") {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (
|
|
539
|
+
state.codexThreadId === undefined ||
|
|
540
|
+
state.kandanThreadId === undefined ||
|
|
541
|
+
control.threadId !== state.codexThreadId
|
|
542
|
+
) {
|
|
543
|
+
return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const interrupted = interruptQueuedMessages(state.queue, control.throughSeq);
|
|
547
|
+
const activeQueuedSeq =
|
|
548
|
+
state.turn.status === "active" || state.turn.status === "starting"
|
|
549
|
+
? state.turn.queuedSeq
|
|
550
|
+
: undefined;
|
|
551
|
+
const targetsActiveTurn =
|
|
552
|
+
control.throughSeq !== undefined && activeQueuedSeq === control.throughSeq;
|
|
553
|
+
|
|
554
|
+
if (!interrupted.ok && !targetsActiveTurn) {
|
|
555
|
+
return { instanceId: args.instanceId, ok: false, error: "queue_empty" };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
switch (state.turn.status) {
|
|
559
|
+
case "active":
|
|
560
|
+
const interruptedActiveSeq = state.turn.queuedSeq;
|
|
561
|
+
await args.codex.request("turn/interrupt", {
|
|
562
|
+
threadId: state.codexThreadId,
|
|
563
|
+
turnId: state.turn.turnId,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
state.queue = interrupted.ok ? interrupted.queue : state.queue;
|
|
567
|
+
state.turn = { status: "idle" };
|
|
568
|
+
clearActiveProcessingState(state, interruptedActiveSeq);
|
|
569
|
+
await publishMessageState(args, state.kandanThreadId, interruptedActiveSeq, {
|
|
570
|
+
status: "processed",
|
|
571
|
+
});
|
|
572
|
+
break;
|
|
573
|
+
case "starting":
|
|
574
|
+
state.queue = interrupted.ok ? interrupted.queue : state.queue;
|
|
575
|
+
state.turn = {
|
|
576
|
+
status: "starting",
|
|
577
|
+
queuedSeq: state.turn.queuedSeq,
|
|
578
|
+
interruptAfterStart: true,
|
|
579
|
+
};
|
|
580
|
+
break;
|
|
581
|
+
case "idle":
|
|
582
|
+
case "completing":
|
|
583
|
+
state.queue = interrupted.ok ? interrupted.queue : state.queue;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
args.log("codex.queued_messages_interrupted", {
|
|
588
|
+
through_seq: control.throughSeq ?? null,
|
|
589
|
+
selected_count: interrupted.ok ? interrupted.selectedCount : 0,
|
|
590
|
+
remaining_count: interrupted.ok ? interrupted.remainingCount : state.queue.length,
|
|
591
|
+
});
|
|
592
|
+
if (interrupted.ok) {
|
|
593
|
+
for (const seq of interrupted.selectedSeqs) {
|
|
594
|
+
await publishMessageState(args, state.kandanThreadId, seq, {
|
|
595
|
+
status: "processing",
|
|
596
|
+
reason: "interrupt requested",
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
601
|
+
return {
|
|
602
|
+
instanceId: args.instanceId,
|
|
603
|
+
ok: true,
|
|
604
|
+
interruptedQueuedMessages: interrupted.ok ? interrupted.selectedCount : 0,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function resolvePendingCodexApprovalRequest(
|
|
609
|
+
args: ChannelSessionContext,
|
|
610
|
+
state: ChannelSessionState,
|
|
611
|
+
control: Extract<KandanControl, { readonly type: "resolve_codex_approval_request" }>,
|
|
612
|
+
): Promise<JsonObject> {
|
|
613
|
+
if (
|
|
614
|
+
state.codexThreadId === undefined ||
|
|
615
|
+
state.kandanThreadId === undefined ||
|
|
616
|
+
control.threadId !== state.codexThreadId
|
|
617
|
+
) {
|
|
618
|
+
return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const approval = state.pendingApprovalRequests.find(
|
|
622
|
+
pending =>
|
|
623
|
+
pending.requestId === control.requestId &&
|
|
624
|
+
pending.sourceSeq === control.sourceSeq,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
if (approval === undefined) {
|
|
628
|
+
return { instanceId: args.instanceId, ok: false, error: "approval_request_not_found" };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
state.pendingApprovalRequests = state.pendingApprovalRequests.filter(
|
|
632
|
+
pending => pending !== approval,
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const codexDecision = control.decision === "approve" ? "accept" : "decline";
|
|
636
|
+
approval.resolve({ decision: codexDecision });
|
|
637
|
+
state.activeProcessingState = { seq: approval.sourceSeq, reason: "streaming response" };
|
|
638
|
+
|
|
639
|
+
await publishMessageState(args, state.kandanThreadId, approval.sourceSeq, {
|
|
640
|
+
status: "processing",
|
|
641
|
+
reason: "streaming response",
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
args.log("codex.approval_request_resolved", {
|
|
645
|
+
request_id: control.requestId,
|
|
646
|
+
source_seq: control.sourceSeq,
|
|
647
|
+
decision: control.decision,
|
|
648
|
+
codex_decision: codexDecision,
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
return { instanceId: args.instanceId, ok: true };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function handleKandanChatEvent(
|
|
655
|
+
args: ChannelSessionContext,
|
|
656
|
+
state: ChannelSessionState,
|
|
657
|
+
runnerIdentity: RunnerIdentity,
|
|
658
|
+
payloadContext: RunnerPayloadContext,
|
|
659
|
+
event: KandanChatEvent,
|
|
660
|
+
): Promise<void> {
|
|
661
|
+
const session = args.options.channelSession;
|
|
662
|
+
|
|
663
|
+
if (
|
|
664
|
+
event.type !== "thread.message" ||
|
|
665
|
+
event.threadId === undefined ||
|
|
666
|
+
event.body.trim() === ""
|
|
667
|
+
) {
|
|
668
|
+
args.log("kandan.message_ignored", {
|
|
669
|
+
seq: event.seq,
|
|
670
|
+
actor_slug: event.actorSlug ?? null,
|
|
671
|
+
actor_user_id: event.actorUserId ?? null,
|
|
672
|
+
reason: "not_thread_message_or_empty",
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (isCodexAuthoredEvent(event)) {
|
|
678
|
+
args.log("kandan.message_ignored", {
|
|
679
|
+
seq: event.seq,
|
|
680
|
+
actor_slug: event.actorSlug ?? null,
|
|
681
|
+
actor_user_id: event.actorUserId ?? null,
|
|
682
|
+
reason: "codex_authored",
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!senderAllowed(session.listenUser, event, runnerIdentity)) {
|
|
688
|
+
args.log("kandan.message_ignored", {
|
|
689
|
+
seq: event.seq,
|
|
690
|
+
actor_slug: event.actorSlug ?? null,
|
|
691
|
+
actor_user_id: event.actorUserId ?? null,
|
|
692
|
+
reason: "sender_not_allowed",
|
|
693
|
+
});
|
|
694
|
+
await publishKandanMessageState(args, event, {
|
|
695
|
+
status: "ignored",
|
|
696
|
+
reason: "sender_not_allowed",
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (state.kandanThreadId === undefined) {
|
|
702
|
+
if (state.rootSeq === undefined || event.replyToSeq !== state.rootSeq) {
|
|
703
|
+
const bound = await bindUnboundHistoricalThread(args, state, event);
|
|
704
|
+
|
|
705
|
+
if (!bound) {
|
|
706
|
+
args.log("kandan.message_ignored", {
|
|
707
|
+
seq: event.seq,
|
|
708
|
+
actor_slug: event.actorSlug ?? null,
|
|
709
|
+
actor_user_id: event.actorUserId ?? null,
|
|
710
|
+
reason: "not_root_reply",
|
|
711
|
+
});
|
|
712
|
+
await publishKandanMessageState(args, event, {
|
|
713
|
+
status: "ignored",
|
|
714
|
+
reason: "not_root_reply",
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
} else {
|
|
719
|
+
state.kandanThreadId = event.threadId;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (event.threadId !== state.kandanThreadId) {
|
|
724
|
+
args.log("kandan.message_ignored", {
|
|
725
|
+
seq: event.seq,
|
|
726
|
+
actor_slug: event.actorSlug ?? null,
|
|
727
|
+
actor_user_id: event.actorUserId ?? null,
|
|
728
|
+
reason: "different_thread",
|
|
729
|
+
});
|
|
730
|
+
await publishKandanMessageState(args, event, {
|
|
731
|
+
status: "ignored",
|
|
732
|
+
reason: "different_thread",
|
|
733
|
+
});
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
state.queue.push({
|
|
738
|
+
seq: event.seq,
|
|
739
|
+
actorSlug: event.actorSlug,
|
|
740
|
+
actorUserId: event.actorUserId,
|
|
741
|
+
body: event.body,
|
|
742
|
+
});
|
|
743
|
+
args.log("kandan.message_queued", {
|
|
744
|
+
seq: event.seq,
|
|
745
|
+
actor_slug: event.actorSlug ?? null,
|
|
746
|
+
actor_user_id: event.actorUserId ?? null,
|
|
747
|
+
queue_depth: state.queue.length,
|
|
748
|
+
});
|
|
749
|
+
await publishKandanMessageState(args, event, { status: "queued" });
|
|
750
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function bindUnboundHistoricalThread(
|
|
754
|
+
args: ChannelSessionContext,
|
|
755
|
+
state: ChannelSessionState,
|
|
756
|
+
event: KandanChatEvent,
|
|
757
|
+
): Promise<boolean> {
|
|
758
|
+
if (event.threadId === undefined || event.replyToSeq === undefined) {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const session = args.options.channelSession;
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const resolved = await pushOk(args.kandan, args.topic, "session:resolve_thread_session", {
|
|
766
|
+
workspace: session.workspaceSlug,
|
|
767
|
+
channel: session.channelSlug,
|
|
768
|
+
thread_id: event.threadId,
|
|
769
|
+
});
|
|
770
|
+
const rootSeq = integerValue(resolved.seq);
|
|
771
|
+
const codexThreadId = stringValue(resolved.codex_thread_id);
|
|
772
|
+
|
|
773
|
+
if (rootSeq !== event.replyToSeq || codexThreadId === undefined) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
state.rootSeq = rootSeq;
|
|
778
|
+
state.kandanThreadId = event.threadId;
|
|
779
|
+
state.codexThreadId = codexThreadId;
|
|
780
|
+
await bindCurrentCodexThread(args, state);
|
|
781
|
+
args.log("kandan.thread_bound_from_historical_reply", {
|
|
782
|
+
seq: event.seq,
|
|
783
|
+
kandan_thread_id: event.threadId,
|
|
784
|
+
root_seq: rootSeq,
|
|
785
|
+
codex_thread_id: codexThreadId,
|
|
786
|
+
});
|
|
787
|
+
return true;
|
|
788
|
+
} catch (error) {
|
|
789
|
+
args.log("kandan.thread_bind_from_historical_reply_failed", {
|
|
790
|
+
seq: event.seq,
|
|
791
|
+
kandan_thread_id: event.threadId,
|
|
792
|
+
message: error instanceof Error ? error.message : String(error),
|
|
793
|
+
});
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function processBufferedKandanEvents(
|
|
799
|
+
args: ChannelSessionContext,
|
|
800
|
+
state: ChannelSessionState,
|
|
801
|
+
runnerIdentity: RunnerIdentity,
|
|
802
|
+
payloadContext: RunnerPayloadContext,
|
|
803
|
+
pendingEvents: readonly KandanChatEvent[],
|
|
804
|
+
): Promise<void> {
|
|
805
|
+
const events = [...pendingEvents].sort(
|
|
806
|
+
(left, right) => left.seq - right.seq,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
for (const event of events) {
|
|
810
|
+
await processKandanChatEvent(args, state, runnerIdentity, event, payloadContext);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function processKandanChatEvent(
|
|
815
|
+
args: ChannelSessionContext,
|
|
816
|
+
state: ChannelSessionState,
|
|
817
|
+
runnerIdentity: RunnerIdentity,
|
|
818
|
+
event: KandanChatEvent,
|
|
819
|
+
payloadContext: RunnerPayloadContext,
|
|
820
|
+
): Promise<void> {
|
|
821
|
+
if (event.seq <= state.minSeq) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
state.minSeq = event.seq;
|
|
826
|
+
args.log("kandan.chat_event", {
|
|
827
|
+
seq: event.seq,
|
|
828
|
+
type: event.type,
|
|
829
|
+
actor_slug: event.actorSlug ?? null,
|
|
830
|
+
actor_user_id: event.actorUserId ?? null,
|
|
831
|
+
thread_id: event.threadId ?? null,
|
|
832
|
+
reply_to_seq: event.replyToSeq ?? null,
|
|
833
|
+
local_runner_event_type: event.localRunnerEventType ?? null,
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
try {
|
|
837
|
+
await handleKandanChatEvent(args, state, runnerIdentity, payloadContext, event);
|
|
838
|
+
} catch (error) {
|
|
839
|
+
args.log("kandan.chat_event_failed", {
|
|
840
|
+
seq: event.seq,
|
|
841
|
+
message: error instanceof Error ? error.message : String(error),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function drainKandanMessageQueue(
|
|
847
|
+
args: ChannelSessionContext,
|
|
848
|
+
state: ChannelSessionState,
|
|
849
|
+
payloadContext: RunnerPayloadContext,
|
|
850
|
+
): Promise<void> {
|
|
851
|
+
if (state.closed || state.turn.status !== "idle") {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const next = state.queue.shift();
|
|
856
|
+
|
|
857
|
+
if (next === undefined) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
state.turn = { status: "starting", queuedSeq: next.seq, interruptAfterStart: false };
|
|
862
|
+
state.activeProcessingState = { seq: next.seq, reason: "starting turn" };
|
|
863
|
+
args.log("codex.turn_starting", {
|
|
864
|
+
queued_seq: next.seq,
|
|
865
|
+
actor_slug: next.actorSlug ?? null,
|
|
866
|
+
actor_user_id: next.actorUserId ?? null,
|
|
867
|
+
codex_thread_id: state.codexThreadId ?? null,
|
|
868
|
+
queue_depth: state.queue.length,
|
|
869
|
+
});
|
|
870
|
+
await publishQueuedMessageState(args, state, next, {
|
|
871
|
+
status: "processing",
|
|
872
|
+
reason: "starting turn",
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
const codexThreadId = state.codexThreadId;
|
|
877
|
+
|
|
878
|
+
if (codexThreadId === undefined) {
|
|
879
|
+
throw new Error("Codex thread is not bound");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (state.kandanThreadId !== undefined) {
|
|
883
|
+
startCodexTypingHeartbeat(args, state, state.kandanThreadId);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const started = await args.codex.request("turn/start", {
|
|
887
|
+
threadId: codexThreadId,
|
|
888
|
+
input: [{ type: "text", text: codexInputForQueuedKandanMessage(next) }],
|
|
889
|
+
...codexTurnRuntimeOverrides(args.options),
|
|
890
|
+
});
|
|
891
|
+
const turnId = extractTurnIdFromResponse(started);
|
|
892
|
+
const interruptAfterStart =
|
|
893
|
+
state.turn.status === "starting" && state.turn.interruptAfterStart;
|
|
894
|
+
state.turn = { status: "active", turnId, queuedSeq: next.seq };
|
|
895
|
+
rememberTurnReplyTarget(state, turnId, next.seq);
|
|
896
|
+
args.log("codex.turn_started", { turn_id: turnId });
|
|
897
|
+
|
|
898
|
+
if (interruptAfterStart) {
|
|
899
|
+
await args.codex.request("turn/interrupt", {
|
|
900
|
+
threadId: codexThreadId,
|
|
901
|
+
turnId,
|
|
902
|
+
});
|
|
903
|
+
state.turn = { status: "idle" };
|
|
904
|
+
clearActiveProcessingState(state, next.seq);
|
|
905
|
+
if (state.kandanThreadId !== undefined) {
|
|
906
|
+
await publishMessageState(args, state.kandanThreadId, next.seq, {
|
|
907
|
+
status: "processed",
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
911
|
+
}
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (isRecoverableCodexThreadError(error) && state.kandanThreadId !== undefined) {
|
|
914
|
+
const oldCodexThreadId = state.codexThreadId;
|
|
915
|
+
try {
|
|
916
|
+
await stopCodexTyping(args, state);
|
|
917
|
+
const newCodexThreadId = await startCodexThread(args.codex, args.options);
|
|
918
|
+
state.codexThreadId = newCodexThreadId;
|
|
919
|
+
args.log("codex.thread_rebound", {
|
|
920
|
+
kandan_thread_id: state.kandanThreadId,
|
|
921
|
+
old_codex_thread_id: oldCodexThreadId ?? null,
|
|
922
|
+
new_codex_thread_id: newCodexThreadId,
|
|
923
|
+
});
|
|
924
|
+
await postCodexThreadReboundMessage(
|
|
925
|
+
args,
|
|
926
|
+
state,
|
|
927
|
+
payloadContext,
|
|
928
|
+
oldCodexThreadId,
|
|
929
|
+
newCodexThreadId,
|
|
930
|
+
);
|
|
931
|
+
state.queue = [next, ...state.queue];
|
|
932
|
+
state.turn = { status: "idle" };
|
|
933
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
934
|
+
return;
|
|
935
|
+
} catch (recoveryError) {
|
|
936
|
+
args.log("codex.thread_rebind_failed", {
|
|
937
|
+
kandan_thread_id: state.kandanThreadId,
|
|
938
|
+
old_codex_thread_id: oldCodexThreadId ?? null,
|
|
939
|
+
message: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
state.turn = { status: "idle" };
|
|
945
|
+
clearActiveProcessingState(state, next.seq);
|
|
946
|
+
await stopCodexTyping(args, state);
|
|
947
|
+
await publishQueuedMessageState(
|
|
948
|
+
args,
|
|
949
|
+
state,
|
|
950
|
+
next,
|
|
951
|
+
{
|
|
952
|
+
status: "failed",
|
|
953
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
954
|
+
},
|
|
955
|
+
);
|
|
956
|
+
args.log("codex.turn_start_failed", {
|
|
957
|
+
queued_seq: next.seq,
|
|
958
|
+
message: error instanceof Error ? error.message : String(error),
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function handleCodexServerRequest(
|
|
964
|
+
args: ChannelSessionContext,
|
|
965
|
+
state: ChannelSessionState,
|
|
966
|
+
payloadContext: RunnerPayloadContext,
|
|
967
|
+
request: JsonRpcRequest,
|
|
968
|
+
): Promise<JsonObject> {
|
|
969
|
+
const params = objectValue(request.params) ?? {};
|
|
970
|
+
const turnId = stringValue(params.turnId);
|
|
971
|
+
|
|
972
|
+
if (codexApprovalRequestCanAutoAccept(args.options, request.method)) {
|
|
973
|
+
args.log("codex.server_request_auto_accepted", {
|
|
974
|
+
method: request.method,
|
|
975
|
+
turn_id: turnId ?? null,
|
|
976
|
+
});
|
|
977
|
+
return { decision: "accept" };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (codexApprovalRequestCanSurface(request.method)) {
|
|
981
|
+
if (turnId === undefined) {
|
|
982
|
+
throw new Error(`Codex approval request missing turn id: ${request.method}`);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return requestKandanApproval(args, state, request, turnId, payloadContext);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const message = `unhandled Codex app-server request: ${request.method}`;
|
|
989
|
+
|
|
990
|
+
args.log("codex.server_request_unhandled", {
|
|
991
|
+
method: request.method,
|
|
992
|
+
turn_id: turnId ?? null,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
if (turnId !== undefined) {
|
|
996
|
+
await failActiveCodexTurn(args, state, turnId, message, payloadContext);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
throw new Error(message);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function codexApprovalRequestCanAutoAccept(
|
|
1003
|
+
options: ChannelSessionRunnerOptions,
|
|
1004
|
+
method: string,
|
|
1005
|
+
): boolean {
|
|
1006
|
+
return (
|
|
1007
|
+
options.channelSession.approvalPolicy === "never" &&
|
|
1008
|
+
(
|
|
1009
|
+
method === "item/commandExecution/requestApproval" ||
|
|
1010
|
+
method === "item/fileChange/requestApproval"
|
|
1011
|
+
)
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function codexApprovalRequestCanSurface(method: string): boolean {
|
|
1016
|
+
return (
|
|
1017
|
+
method === "item/commandExecution/requestApproval" ||
|
|
1018
|
+
method === "item/fileChange/requestApproval"
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function requestKandanApproval(
|
|
1023
|
+
args: ChannelSessionContext,
|
|
1024
|
+
state: ChannelSessionState,
|
|
1025
|
+
request: JsonRpcRequest,
|
|
1026
|
+
turnId: string,
|
|
1027
|
+
payloadContext: RunnerPayloadContext,
|
|
1028
|
+
): Promise<JsonObject> {
|
|
1029
|
+
const sourceSeq = activeQueuedSeqForTurn(state, turnId);
|
|
1030
|
+
|
|
1031
|
+
if (sourceSeq === undefined || state.kandanThreadId === undefined) {
|
|
1032
|
+
const message = `Codex approval request has no active Kandan source message: ${request.method}`;
|
|
1033
|
+
await failActiveCodexTurn(args, state, turnId, message, payloadContext);
|
|
1034
|
+
throw new Error(message);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const approval = codexApprovalMessageState(request);
|
|
1038
|
+
state.activeProcessingState = { seq: sourceSeq, reason: "awaiting approval", approval };
|
|
1039
|
+
await publishMessageState(args, state.kandanThreadId, sourceSeq, {
|
|
1040
|
+
status: "processing",
|
|
1041
|
+
reason: "awaiting approval",
|
|
1042
|
+
approval,
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
args.log("codex.approval_request_pending", {
|
|
1046
|
+
request_id: approval.requestId,
|
|
1047
|
+
source_seq: sourceSeq,
|
|
1048
|
+
turn_id: turnId,
|
|
1049
|
+
method: request.method,
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
return new Promise<JsonObject>((resolve, reject) => {
|
|
1053
|
+
state.pendingApprovalRequests = [
|
|
1054
|
+
...state.pendingApprovalRequests,
|
|
1055
|
+
{
|
|
1056
|
+
requestId: approval.requestId,
|
|
1057
|
+
sourceSeq,
|
|
1058
|
+
turnId,
|
|
1059
|
+
resolve,
|
|
1060
|
+
reject,
|
|
1061
|
+
},
|
|
1062
|
+
];
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function codexApprovalMessageState(request: JsonRpcRequest): CodexApprovalMessageState {
|
|
1067
|
+
const params = objectValue(request.params) ?? {};
|
|
1068
|
+
const command = stringValue(params.command) ?? stringValue(params.cmd);
|
|
1069
|
+
const filePath =
|
|
1070
|
+
stringValue(params.path) ??
|
|
1071
|
+
stringValue(params.filePath) ??
|
|
1072
|
+
stringValue(params.file);
|
|
1073
|
+
const summary =
|
|
1074
|
+
command ??
|
|
1075
|
+
filePath ??
|
|
1076
|
+
stringValue(params.reason) ??
|
|
1077
|
+
stringValue(params.summary) ??
|
|
1078
|
+
request.method;
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
requestId: String(request.id),
|
|
1082
|
+
kind: request.method,
|
|
1083
|
+
summary,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function rejectPendingApprovalRequests(state: ChannelSessionState, error: Error): void {
|
|
1088
|
+
const pendingApprovals = state.pendingApprovalRequests;
|
|
1089
|
+
state.pendingApprovalRequests = [];
|
|
1090
|
+
pendingApprovals.forEach(approval => approval.reject(error));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function rejectPendingApprovalRequestsForTurn(
|
|
1094
|
+
state: ChannelSessionState,
|
|
1095
|
+
turnId: string,
|
|
1096
|
+
error: Error,
|
|
1097
|
+
): void {
|
|
1098
|
+
const pendingApprovals = state.pendingApprovalRequests;
|
|
1099
|
+
state.pendingApprovalRequests = pendingApprovals.filter(approval => approval.turnId !== turnId);
|
|
1100
|
+
pendingApprovals
|
|
1101
|
+
.filter(approval => approval.turnId === turnId)
|
|
1102
|
+
.forEach(approval => approval.reject(error));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function forwardCompletedCodexTurn(
|
|
1106
|
+
args: ChannelSessionContext,
|
|
1107
|
+
state: ChannelSessionState,
|
|
1108
|
+
turnId: string,
|
|
1109
|
+
payloadContext: RunnerPayloadContext,
|
|
1110
|
+
): Promise<void> {
|
|
1111
|
+
if (isLocalTuiTurn(state, turnId)) {
|
|
1112
|
+
ensureKandanThreadForLocalTuiTurn(state);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (
|
|
1116
|
+
state.kandanThreadId === undefined ||
|
|
1117
|
+
state.codexThreadId === undefined ||
|
|
1118
|
+
state.forwardedTurnIds.includes(turnId) ||
|
|
1119
|
+
state.forwardingTurnIds.includes(turnId) ||
|
|
1120
|
+
!turnCanForward(state, turnId)
|
|
1121
|
+
) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const completingQueuedSeq =
|
|
1126
|
+
state.turn.status === "active" && state.turn.turnId === turnId
|
|
1127
|
+
? state.turn.queuedSeq
|
|
1128
|
+
: undefined;
|
|
1129
|
+
const completingActiveTurn = completingQueuedSeq !== undefined;
|
|
1130
|
+
const completingLocalTuiTurn = isLocalTuiTurn(state, turnId);
|
|
1131
|
+
if (completingQueuedSeq !== undefined) {
|
|
1132
|
+
state.turn = { status: "completing", turnId, queuedSeq: completingQueuedSeq };
|
|
1133
|
+
}
|
|
1134
|
+
await waitForPendingTuiInputMirror(state, turnId);
|
|
1135
|
+
await waitForStreamingForwardChains(state);
|
|
1136
|
+
rememberForwardingTurnId(state, turnId);
|
|
1137
|
+
forgetRetryableTurnId(state, turnId);
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
const session = args.options.channelSession;
|
|
1141
|
+
const read = await args.codex.request("thread/read", {
|
|
1142
|
+
threadId: state.codexThreadId,
|
|
1143
|
+
includeTurns: true,
|
|
1144
|
+
});
|
|
1145
|
+
const tuiInputMessages =
|
|
1146
|
+
isLocalTuiTurn(state, turnId)
|
|
1147
|
+
? codexUserInputMessagesForTurn(read, turnId)
|
|
1148
|
+
: [];
|
|
1149
|
+
const messages = codexOutputMessagesForTurn(read, turnId);
|
|
1150
|
+
args.log("codex.turn_completed", {
|
|
1151
|
+
turn_id: turnId,
|
|
1152
|
+
tui_input_count: tuiInputMessages.length,
|
|
1153
|
+
output_count: messages.length,
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
if (isLocalTuiTurn(state, turnId)) {
|
|
1157
|
+
ensureKandanThreadForLocalTuiTurn(state);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
for (const message of tuiInputMessages) {
|
|
1161
|
+
await mirrorLocalTuiInputMessage(args, state, turnId, message, payloadContext);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (
|
|
1165
|
+
completingLocalTuiTurn &&
|
|
1166
|
+
sourceMessageSeqForTurn(state, turnId) === undefined
|
|
1167
|
+
) {
|
|
1168
|
+
throw new LogicalProjectionError(
|
|
1169
|
+
`Cannot forward Codex output for local TUI turn ${turnId} before mirroring its human input`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
for (const message of messages) {
|
|
1174
|
+
const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
|
|
1175
|
+
const rootSeq = state.rootSeq;
|
|
1176
|
+
const streamedStructured = resolveStreamingStructuredOutputForCompletedMessage(
|
|
1177
|
+
state,
|
|
1178
|
+
message.itemKey,
|
|
1179
|
+
message.structured,
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
if (streamedStructured !== undefined) {
|
|
1183
|
+
await editCodexStructuredOutput(
|
|
1184
|
+
args,
|
|
1185
|
+
state,
|
|
1186
|
+
streamedStructured.seq,
|
|
1187
|
+
message.body,
|
|
1188
|
+
message.structured,
|
|
1189
|
+
);
|
|
1190
|
+
forgetStreamingStructuredOutput(state, message.itemKey, message.structured);
|
|
1191
|
+
args.log("kandan.codex_output_forwarded", {
|
|
1192
|
+
turn_id: turnId,
|
|
1193
|
+
item_key: message.itemKey,
|
|
1194
|
+
structured_kind: stringValue(message.structured.kind) ?? null,
|
|
1195
|
+
command: stringValue(message.structured.command) ?? null,
|
|
1196
|
+
file_paths: fileChangePaths(message.structured),
|
|
1197
|
+
});
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (
|
|
1202
|
+
stringValue(message.structured.kind) === "codex_terminal_input" &&
|
|
1203
|
+
state.forwardedTerminalInputKeys.includes(message.itemKey)
|
|
1204
|
+
) {
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const streamed = resolveStreamingAssistantOutputForCompletedMessage(
|
|
1209
|
+
state,
|
|
1210
|
+
turnId,
|
|
1211
|
+
message.itemKey,
|
|
1212
|
+
message.body,
|
|
1213
|
+
message.structured,
|
|
1214
|
+
);
|
|
1215
|
+
|
|
1216
|
+
switch (streamed.status) {
|
|
1217
|
+
case "none":
|
|
1218
|
+
await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1219
|
+
workspace: session.workspaceSlug,
|
|
1220
|
+
channel: session.channelSlug,
|
|
1221
|
+
thread_id: state.kandanThreadId,
|
|
1222
|
+
body: message.body,
|
|
1223
|
+
payload: {
|
|
1224
|
+
...localRunnerPayload(
|
|
1225
|
+
args.options,
|
|
1226
|
+
args.instanceId,
|
|
1227
|
+
"codex_output",
|
|
1228
|
+
state.codexThreadId,
|
|
1229
|
+
payloadContext,
|
|
1230
|
+
sourceMessageSeq,
|
|
1231
|
+
),
|
|
1232
|
+
...(rootSeq === undefined ? {} : { reply_to_seq: rootSeq }),
|
|
1233
|
+
structured: message.structured,
|
|
1234
|
+
},
|
|
1235
|
+
client_message_id: `local-codex-${args.instanceId}-${turnId}-${message.itemKey}`,
|
|
1236
|
+
});
|
|
1237
|
+
break;
|
|
1238
|
+
case "matched":
|
|
1239
|
+
await editStreamedCodexOutput(
|
|
1240
|
+
args,
|
|
1241
|
+
state,
|
|
1242
|
+
streamed.output.seq,
|
|
1243
|
+
message.itemKey,
|
|
1244
|
+
message.body,
|
|
1245
|
+
"completed",
|
|
1246
|
+
);
|
|
1247
|
+
forgetStreamingAssistantOutput(state, streamed.output.itemKey);
|
|
1248
|
+
break;
|
|
1249
|
+
case "ambiguous":
|
|
1250
|
+
throw new LogicalProjectionError(
|
|
1251
|
+
`Cannot reconcile completed assistant item ${message.itemKey} for turn ${turnId}; ${streamed.candidateCount} active streamed assistant outputs exist`,
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
args.log("kandan.codex_output_forwarded", {
|
|
1255
|
+
turn_id: turnId,
|
|
1256
|
+
item_key: message.itemKey,
|
|
1257
|
+
structured_kind: stringValue(message.structured.kind) ?? null,
|
|
1258
|
+
command: stringValue(message.structured.command) ?? null,
|
|
1259
|
+
file_paths: fileChangePaths(message.structured),
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
rememberForwardedTurnId(state, turnId);
|
|
1264
|
+
forgetLocalTuiTurnId(state, turnId);
|
|
1265
|
+
if (completingQueuedSeq !== undefined) {
|
|
1266
|
+
await publishMessageState(args, state.kandanThreadId, completingQueuedSeq, {
|
|
1267
|
+
status: "processed",
|
|
1268
|
+
});
|
|
1269
|
+
clearActiveProcessingState(state, completingQueuedSeq);
|
|
1270
|
+
}
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
if (error instanceof LogicalProjectionError) {
|
|
1273
|
+
if (completingQueuedSeq !== undefined && state.kandanThreadId !== undefined) {
|
|
1274
|
+
await publishMessageState(args, state.kandanThreadId, completingQueuedSeq, {
|
|
1275
|
+
status: "failed",
|
|
1276
|
+
reason: error.message,
|
|
1277
|
+
});
|
|
1278
|
+
clearActiveProcessingState(state, completingQueuedSeq);
|
|
1279
|
+
}
|
|
1280
|
+
forgetLocalTuiTurnId(state, turnId);
|
|
1281
|
+
rememberForwardedTurnId(state, turnId);
|
|
1282
|
+
args.log("codex.logical_projection_failed", {
|
|
1283
|
+
turn_id: turnId,
|
|
1284
|
+
message: error.message,
|
|
1285
|
+
});
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
rememberRetryableTurnId(state, turnId);
|
|
1290
|
+
throw error;
|
|
1291
|
+
} finally {
|
|
1292
|
+
forgetForwardingTurnId(state, turnId);
|
|
1293
|
+
if (
|
|
1294
|
+
completingActiveTurn &&
|
|
1295
|
+
state.turn.status === "completing" &&
|
|
1296
|
+
state.turn.turnId === turnId
|
|
1297
|
+
) {
|
|
1298
|
+
state.turn = { status: "idle" };
|
|
1299
|
+
await stopCodexTyping(args, state);
|
|
1300
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
1301
|
+
}
|
|
1302
|
+
if (completingLocalTuiTurn && !completingActiveTurn) {
|
|
1303
|
+
await stopCodexTyping(args, state);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function forwardAssistantDelta(
|
|
1309
|
+
args: ChannelSessionContext,
|
|
1310
|
+
state: ChannelSessionState,
|
|
1311
|
+
params: JsonObject,
|
|
1312
|
+
payloadContext: RunnerPayloadContext,
|
|
1313
|
+
): Promise<void> {
|
|
1314
|
+
if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const delta = codexAssistantDeltaFromNotification(params);
|
|
1319
|
+
|
|
1320
|
+
if (delta === undefined) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (
|
|
1325
|
+
delta.turnId !== undefined &&
|
|
1326
|
+
turnIsFinalizingOrForwarded(state, delta.turnId)
|
|
1327
|
+
) {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (delta.turnId !== undefined) {
|
|
1332
|
+
await waitForPendingTuiInputMirror(state, delta.turnId);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
if (
|
|
1336
|
+
delta.turnId !== undefined &&
|
|
1337
|
+
isLocalTuiTurn(state, delta.turnId) &&
|
|
1338
|
+
sourceMessageSeqForTurn(state, delta.turnId) === undefined
|
|
1339
|
+
) {
|
|
1340
|
+
args.log("codex.delta_waiting_for_tui_input_projection", {
|
|
1341
|
+
turn_id: delta.turnId,
|
|
1342
|
+
item_key: delta.itemKey,
|
|
1343
|
+
});
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const existing = findStreamingAssistantOutput(state, delta.itemKey);
|
|
1348
|
+
const nextContent = `${existing?.content ?? ""}${delta.delta}`;
|
|
1349
|
+
const sourceMessageSeq =
|
|
1350
|
+
delta.turnId === undefined ? undefined : sourceMessageSeqForTurn(state, delta.turnId);
|
|
1351
|
+
const rootSeq = state.rootSeq;
|
|
1352
|
+
|
|
1353
|
+
if (delta.turnId !== undefined && sourceMessageSeq === undefined) {
|
|
1354
|
+
args.log("codex.delta_without_source_message", {
|
|
1355
|
+
turn_id: delta.turnId,
|
|
1356
|
+
item_key: delta.itemKey,
|
|
1357
|
+
});
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (existing === undefined && nextContent.trim() === "") {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (existing === undefined) {
|
|
1366
|
+
const session = args.options.channelSession;
|
|
1367
|
+
const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1368
|
+
workspace: session.workspaceSlug,
|
|
1369
|
+
channel: session.channelSlug,
|
|
1370
|
+
thread_id: state.kandanThreadId,
|
|
1371
|
+
body: nextContent,
|
|
1372
|
+
payload: {
|
|
1373
|
+
...localRunnerPayload(
|
|
1374
|
+
args.options,
|
|
1375
|
+
args.instanceId,
|
|
1376
|
+
"codex_output",
|
|
1377
|
+
state.codexThreadId,
|
|
1378
|
+
payloadContext,
|
|
1379
|
+
sourceMessageSeq,
|
|
1380
|
+
),
|
|
1381
|
+
...(rootSeq === undefined ? {} : { reply_to_seq: rootSeq }),
|
|
1382
|
+
structured: codexAssistantStructuredMessage(delta.itemKey, nextContent, "streaming"),
|
|
1383
|
+
},
|
|
1384
|
+
client_message_id: streamingClientMessageId(args.instanceId, delta),
|
|
1385
|
+
});
|
|
1386
|
+
const seq = integerValue(reply.seq);
|
|
1387
|
+
|
|
1388
|
+
if (seq !== undefined) {
|
|
1389
|
+
rememberStreamingAssistantOutput(state, {
|
|
1390
|
+
itemKey: delta.itemKey,
|
|
1391
|
+
turnId: delta.turnId,
|
|
1392
|
+
seq,
|
|
1393
|
+
content: nextContent,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
} else {
|
|
1397
|
+
await editStreamedCodexOutput(
|
|
1398
|
+
args,
|
|
1399
|
+
state,
|
|
1400
|
+
existing.seq,
|
|
1401
|
+
delta.itemKey,
|
|
1402
|
+
nextContent,
|
|
1403
|
+
"streaming",
|
|
1404
|
+
);
|
|
1405
|
+
rememberStreamingAssistantOutput(state, {
|
|
1406
|
+
...existing,
|
|
1407
|
+
content: nextContent,
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
args.log("kandan.codex_delta_forwarded", {
|
|
1412
|
+
item_key: delta.itemKey,
|
|
1413
|
+
turn_id: delta.turnId ?? null,
|
|
1414
|
+
content_length: nextContent.length,
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function enqueueAssistantDelta(
|
|
1419
|
+
args: ChannelSessionContext,
|
|
1420
|
+
state: ChannelSessionState,
|
|
1421
|
+
params: JsonObject,
|
|
1422
|
+
payloadContext: RunnerPayloadContext,
|
|
1423
|
+
): void {
|
|
1424
|
+
const previous = state.assistantDeltaForwardChain;
|
|
1425
|
+
const next = previous
|
|
1426
|
+
.catch(() => undefined)
|
|
1427
|
+
.then(() => forwardAssistantDelta(args, state, params, payloadContext));
|
|
1428
|
+
|
|
1429
|
+
state.assistantDeltaForwardChain = next.catch(error => {
|
|
1430
|
+
args.log("codex.delta_forward_failed", {
|
|
1431
|
+
turn_id: codexNotificationTurnId(params) ?? null,
|
|
1432
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function enqueueReasoningDelta(
|
|
1438
|
+
args: ChannelSessionContext,
|
|
1439
|
+
state: ChannelSessionState,
|
|
1440
|
+
params: JsonObject,
|
|
1441
|
+
payloadContext: RunnerPayloadContext,
|
|
1442
|
+
): void {
|
|
1443
|
+
const previous = state.reasoningDeltaForwardChain;
|
|
1444
|
+
const next = previous
|
|
1445
|
+
.catch(() => undefined)
|
|
1446
|
+
.then(() => forwardReasoningDelta(args, state, params, payloadContext));
|
|
1447
|
+
|
|
1448
|
+
state.reasoningDeltaForwardChain = next.catch(error => {
|
|
1449
|
+
args.log("codex.reasoning_delta_forward_failed", {
|
|
1450
|
+
turn_id: codexNotificationTurnId(params) ?? null,
|
|
1451
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function enqueueCommandOutputDelta(
|
|
1457
|
+
args: ChannelSessionContext,
|
|
1458
|
+
state: ChannelSessionState,
|
|
1459
|
+
params: JsonObject,
|
|
1460
|
+
payloadContext: RunnerPayloadContext,
|
|
1461
|
+
): void {
|
|
1462
|
+
const previous = state.commandOutputForwardChain;
|
|
1463
|
+
const next = previous
|
|
1464
|
+
.catch(() => undefined)
|
|
1465
|
+
.then(() => forwardCommandOutputDelta(args, state, params, payloadContext));
|
|
1466
|
+
|
|
1467
|
+
state.commandOutputForwardChain = next.catch(error => {
|
|
1468
|
+
args.log("codex.command_output_forward_failed", {
|
|
1469
|
+
turn_id: codexNotificationTurnId(params) ?? null,
|
|
1470
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function enqueueFileChangeDelta(
|
|
1476
|
+
args: ChannelSessionContext,
|
|
1477
|
+
state: ChannelSessionState,
|
|
1478
|
+
params: JsonObject,
|
|
1479
|
+
payloadContext: RunnerPayloadContext,
|
|
1480
|
+
): void {
|
|
1481
|
+
const previous = state.fileChangeForwardChain;
|
|
1482
|
+
const next = previous
|
|
1483
|
+
.catch(() => undefined)
|
|
1484
|
+
.then(() => forwardFileChangeDelta(args, state, params, payloadContext));
|
|
1485
|
+
|
|
1486
|
+
state.fileChangeForwardChain = next.catch(error => {
|
|
1487
|
+
args.log("codex.file_change_forward_failed", {
|
|
1488
|
+
turn_id: codexNotificationTurnId(params) ?? null,
|
|
1489
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1490
|
+
});
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function enqueueTerminalInput(
|
|
1495
|
+
args: ChannelSessionContext,
|
|
1496
|
+
state: ChannelSessionState,
|
|
1497
|
+
params: JsonObject,
|
|
1498
|
+
payloadContext: RunnerPayloadContext,
|
|
1499
|
+
): void {
|
|
1500
|
+
const previous = state.terminalInputForwardChain;
|
|
1501
|
+
const next = previous
|
|
1502
|
+
.catch(() => undefined)
|
|
1503
|
+
.then(() => forwardTerminalInput(args, state, params, payloadContext));
|
|
1504
|
+
|
|
1505
|
+
state.terminalInputForwardChain = next.catch(error => {
|
|
1506
|
+
args.log("codex.terminal_input_forward_failed", {
|
|
1507
|
+
turn_id: codexNotificationTurnId(params) ?? null,
|
|
1508
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function enqueueWebSearchProgress(
|
|
1514
|
+
args: ChannelSessionContext,
|
|
1515
|
+
state: ChannelSessionState,
|
|
1516
|
+
params: JsonObject,
|
|
1517
|
+
payloadContext: RunnerPayloadContext,
|
|
1518
|
+
): void {
|
|
1519
|
+
const previous = state.webSearchProgressForwardChain;
|
|
1520
|
+
const next = previous
|
|
1521
|
+
.catch(() => undefined)
|
|
1522
|
+
.then(() => forwardWebSearchProgress(args, state, params, payloadContext));
|
|
1523
|
+
|
|
1524
|
+
state.webSearchProgressForwardChain = next.catch(error => {
|
|
1525
|
+
args.log("codex.web_search_progress_forward_failed", {
|
|
1526
|
+
turn_id: codexNotificationTurnId(params) ?? null,
|
|
1527
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function enqueueWebSearchProgressCompletion(
|
|
1533
|
+
args: ChannelSessionContext,
|
|
1534
|
+
state: ChannelSessionState,
|
|
1535
|
+
turnId: string,
|
|
1536
|
+
): void {
|
|
1537
|
+
const previous = state.webSearchProgressForwardChain;
|
|
1538
|
+
const next = previous
|
|
1539
|
+
.catch(() => undefined)
|
|
1540
|
+
.then(() => completeWebSearchProgress(args, state, turnId));
|
|
1541
|
+
|
|
1542
|
+
state.webSearchProgressForwardChain = next.catch(error => {
|
|
1543
|
+
args.log("codex.web_search_progress_completion_failed", {
|
|
1544
|
+
turn_id: turnId,
|
|
1545
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function enqueueFileChangeCompletion(
|
|
1551
|
+
args: ChannelSessionContext,
|
|
1552
|
+
state: ChannelSessionState,
|
|
1553
|
+
turnId: string,
|
|
1554
|
+
): void {
|
|
1555
|
+
const previous = state.fileChangeForwardChain;
|
|
1556
|
+
const next = previous
|
|
1557
|
+
.catch(() => undefined)
|
|
1558
|
+
.then(() => completeFileChangeOutputs(args, state, turnId));
|
|
1559
|
+
|
|
1560
|
+
state.fileChangeForwardChain = next.catch(error => {
|
|
1561
|
+
args.log("codex.file_change_completion_failed", {
|
|
1562
|
+
turn_id: turnId,
|
|
1563
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1564
|
+
});
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
async function forwardReasoningDelta(
|
|
1569
|
+
args: ChannelSessionContext,
|
|
1570
|
+
state: ChannelSessionState,
|
|
1571
|
+
params: JsonObject,
|
|
1572
|
+
payloadContext: RunnerPayloadContext,
|
|
1573
|
+
): Promise<void> {
|
|
1574
|
+
if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const delta = codexReasoningDeltaFromNotification(params);
|
|
1579
|
+
|
|
1580
|
+
if (delta === undefined) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const turnId = delta.turnId ?? activeTurnId(state);
|
|
1585
|
+
|
|
1586
|
+
if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
|
|
1591
|
+
|
|
1592
|
+
if (sourceMessageSeq === undefined) {
|
|
1593
|
+
args.log("codex.reasoning_without_source_message", {
|
|
1594
|
+
turn_id: turnId,
|
|
1595
|
+
item_key: delta.itemKey,
|
|
1596
|
+
});
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const existing = findStreamingReasoningOutput(state, delta.itemKey);
|
|
1601
|
+
const nextContent = `${existing?.content ?? ""}${delta.delta}`;
|
|
1602
|
+
|
|
1603
|
+
if (existing === undefined && nextContent.trim() === "") {
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (existing === undefined) {
|
|
1608
|
+
const session = args.options.channelSession;
|
|
1609
|
+
const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1610
|
+
workspace: session.workspaceSlug,
|
|
1611
|
+
channel: session.channelSlug,
|
|
1612
|
+
thread_id: state.kandanThreadId,
|
|
1613
|
+
body: nextContent,
|
|
1614
|
+
payload: {
|
|
1615
|
+
...localRunnerPayload(
|
|
1616
|
+
args.options,
|
|
1617
|
+
args.instanceId,
|
|
1618
|
+
"codex_output",
|
|
1619
|
+
state.codexThreadId,
|
|
1620
|
+
payloadContext,
|
|
1621
|
+
sourceMessageSeq,
|
|
1622
|
+
),
|
|
1623
|
+
...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
|
|
1624
|
+
structured: codexReasoningStructuredMessage(delta.itemKey, nextContent, "streaming"),
|
|
1625
|
+
},
|
|
1626
|
+
client_message_id: streamingClientMessageId(args.instanceId, {
|
|
1627
|
+
itemKey: `reasoning:${delta.itemKey}`,
|
|
1628
|
+
turnId,
|
|
1629
|
+
}),
|
|
1630
|
+
});
|
|
1631
|
+
const seq = integerValue(reply.seq);
|
|
1632
|
+
|
|
1633
|
+
if (seq !== undefined) {
|
|
1634
|
+
rememberStreamingReasoningOutput(state, {
|
|
1635
|
+
itemKey: delta.itemKey,
|
|
1636
|
+
turnId,
|
|
1637
|
+
seq,
|
|
1638
|
+
content: nextContent,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
} else {
|
|
1642
|
+
await editCodexStructuredOutput(
|
|
1643
|
+
args,
|
|
1644
|
+
state,
|
|
1645
|
+
existing.seq,
|
|
1646
|
+
nextContent,
|
|
1647
|
+
codexReasoningStructuredMessage(delta.itemKey, nextContent, "streaming"),
|
|
1648
|
+
);
|
|
1649
|
+
rememberStreamingReasoningOutput(state, { ...existing, content: nextContent });
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
args.log("kandan.codex_reasoning_delta_forwarded", {
|
|
1653
|
+
item_key: delta.itemKey,
|
|
1654
|
+
turn_id: turnId,
|
|
1655
|
+
content_length: nextContent.length,
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function forwardCommandOutputDelta(
|
|
1660
|
+
args: ChannelSessionContext,
|
|
1661
|
+
state: ChannelSessionState,
|
|
1662
|
+
params: JsonObject,
|
|
1663
|
+
payloadContext: RunnerPayloadContext,
|
|
1664
|
+
): Promise<void> {
|
|
1665
|
+
if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const delta = codexCommandOutputDeltaFromNotification(params);
|
|
1670
|
+
|
|
1671
|
+
if (delta === undefined) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const turnId = delta.turnId ?? activeTurnId(state);
|
|
1676
|
+
|
|
1677
|
+
if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
|
|
1682
|
+
|
|
1683
|
+
if (sourceMessageSeq === undefined) {
|
|
1684
|
+
args.log("codex.command_output_without_source_message", {
|
|
1685
|
+
turn_id: turnId,
|
|
1686
|
+
item_key: delta.itemKey,
|
|
1687
|
+
});
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const existing = findStreamingCommandOutput(state, delta.itemKey);
|
|
1692
|
+
const output = `${existing?.output ?? ""}${delta.delta}`;
|
|
1693
|
+
const body = commandOutputBody("command output", output);
|
|
1694
|
+
const structured = codexCommandExecutionStructuredMessage(
|
|
1695
|
+
delta.itemKey,
|
|
1696
|
+
{
|
|
1697
|
+
command: "command output",
|
|
1698
|
+
output,
|
|
1699
|
+
processId: delta.processId,
|
|
1700
|
+
stream: delta.stream,
|
|
1701
|
+
},
|
|
1702
|
+
"streaming",
|
|
1703
|
+
);
|
|
1704
|
+
|
|
1705
|
+
if (existing === undefined) {
|
|
1706
|
+
const session = args.options.channelSession;
|
|
1707
|
+
const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1708
|
+
workspace: session.workspaceSlug,
|
|
1709
|
+
channel: session.channelSlug,
|
|
1710
|
+
thread_id: state.kandanThreadId,
|
|
1711
|
+
body,
|
|
1712
|
+
payload: {
|
|
1713
|
+
...localRunnerPayload(
|
|
1714
|
+
args.options,
|
|
1715
|
+
args.instanceId,
|
|
1716
|
+
"codex_output",
|
|
1717
|
+
state.codexThreadId,
|
|
1718
|
+
payloadContext,
|
|
1719
|
+
sourceMessageSeq,
|
|
1720
|
+
),
|
|
1721
|
+
...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
|
|
1722
|
+
structured,
|
|
1723
|
+
},
|
|
1724
|
+
client_message_id: streamingClientMessageId(args.instanceId, {
|
|
1725
|
+
itemKey: `command:${delta.itemKey}`,
|
|
1726
|
+
turnId,
|
|
1727
|
+
}),
|
|
1728
|
+
});
|
|
1729
|
+
const seq = integerValue(reply.seq);
|
|
1730
|
+
|
|
1731
|
+
if (seq !== undefined) {
|
|
1732
|
+
rememberStreamingCommandOutput(state, {
|
|
1733
|
+
itemKey: delta.itemKey,
|
|
1734
|
+
turnId,
|
|
1735
|
+
seq,
|
|
1736
|
+
output,
|
|
1737
|
+
processId: delta.processId,
|
|
1738
|
+
stream: delta.stream,
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
} else {
|
|
1742
|
+
await editCodexStructuredOutput(args, state, existing.seq, body, structured);
|
|
1743
|
+
rememberStreamingCommandOutput(state, {
|
|
1744
|
+
...existing,
|
|
1745
|
+
output,
|
|
1746
|
+
processId: delta.processId ?? existing.processId,
|
|
1747
|
+
stream: delta.stream,
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
args.log("kandan.codex_command_output_forwarded", {
|
|
1752
|
+
item_key: delta.itemKey,
|
|
1753
|
+
turn_id: turnId,
|
|
1754
|
+
process_id: delta.processId ?? null,
|
|
1755
|
+
stream: delta.stream,
|
|
1756
|
+
output_length: output.length,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
async function forwardFileChangeDelta(
|
|
1761
|
+
args: ChannelSessionContext,
|
|
1762
|
+
state: ChannelSessionState,
|
|
1763
|
+
params: JsonObject,
|
|
1764
|
+
payloadContext: RunnerPayloadContext,
|
|
1765
|
+
): Promise<void> {
|
|
1766
|
+
if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const delta = codexFileChangeDeltaFromNotification(params);
|
|
1771
|
+
|
|
1772
|
+
if (delta === undefined) {
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const turnId = delta.turnId ?? activeTurnId(state);
|
|
1777
|
+
|
|
1778
|
+
if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
|
|
1783
|
+
|
|
1784
|
+
if (sourceMessageSeq === undefined) {
|
|
1785
|
+
args.log("codex.file_change_without_source_message", {
|
|
1786
|
+
turn_id: turnId,
|
|
1787
|
+
item_key: delta.itemKey,
|
|
1788
|
+
});
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const existing = findStreamingFileChangeOutput(state, delta.itemKey);
|
|
1793
|
+
const patchText = `${existing?.patchText ?? ""}${delta.patchText}`;
|
|
1794
|
+
const structured = codexFileChangeStructuredMessage(
|
|
1795
|
+
delta.itemKey,
|
|
1796
|
+
patchText,
|
|
1797
|
+
"streaming",
|
|
1798
|
+
"started",
|
|
1799
|
+
);
|
|
1800
|
+
|
|
1801
|
+
if (existing === undefined) {
|
|
1802
|
+
const session = args.options.channelSession;
|
|
1803
|
+
const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1804
|
+
workspace: session.workspaceSlug,
|
|
1805
|
+
channel: session.channelSlug,
|
|
1806
|
+
thread_id: state.kandanThreadId,
|
|
1807
|
+
body: patchText,
|
|
1808
|
+
payload: {
|
|
1809
|
+
...localRunnerPayload(
|
|
1810
|
+
args.options,
|
|
1811
|
+
args.instanceId,
|
|
1812
|
+
"codex_output",
|
|
1813
|
+
state.codexThreadId,
|
|
1814
|
+
payloadContext,
|
|
1815
|
+
sourceMessageSeq,
|
|
1816
|
+
),
|
|
1817
|
+
...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
|
|
1818
|
+
structured,
|
|
1819
|
+
},
|
|
1820
|
+
client_message_id: streamingClientMessageId(args.instanceId, {
|
|
1821
|
+
itemKey: `file-change:${delta.itemKey}`,
|
|
1822
|
+
turnId,
|
|
1823
|
+
}),
|
|
1824
|
+
});
|
|
1825
|
+
const seq = integerValue(reply.seq);
|
|
1826
|
+
|
|
1827
|
+
if (seq !== undefined) {
|
|
1828
|
+
rememberStreamingFileChangeOutput(state, {
|
|
1829
|
+
itemKey: delta.itemKey,
|
|
1830
|
+
turnId,
|
|
1831
|
+
seq,
|
|
1832
|
+
patchText,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
} else {
|
|
1836
|
+
await editCodexStructuredOutput(args, state, existing.seq, patchText, structured);
|
|
1837
|
+
rememberStreamingFileChangeOutput(state, {
|
|
1838
|
+
...existing,
|
|
1839
|
+
patchText,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
args.log("kandan.codex_file_change_forwarded", {
|
|
1844
|
+
item_key: delta.itemKey,
|
|
1845
|
+
turn_id: turnId,
|
|
1846
|
+
patch_length: patchText.length,
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
async function forwardTerminalInput(
|
|
1851
|
+
args: ChannelSessionContext,
|
|
1852
|
+
state: ChannelSessionState,
|
|
1853
|
+
params: JsonObject,
|
|
1854
|
+
payloadContext: RunnerPayloadContext,
|
|
1855
|
+
): Promise<void> {
|
|
1856
|
+
if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const terminal = codexTerminalInputFromNotification(params);
|
|
1861
|
+
|
|
1862
|
+
if (terminal === undefined || state.forwardedTerminalInputKeys.includes(terminal.itemKey)) {
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
const turnId = terminal.turnId ?? activeTurnId(state);
|
|
1867
|
+
|
|
1868
|
+
if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
|
|
1873
|
+
|
|
1874
|
+
if (sourceMessageSeq === undefined) {
|
|
1875
|
+
args.log("codex.terminal_input_without_source_message", {
|
|
1876
|
+
turn_id: turnId,
|
|
1877
|
+
item_key: terminal.itemKey,
|
|
1878
|
+
});
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const session = args.options.channelSession;
|
|
1883
|
+
await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1884
|
+
workspace: session.workspaceSlug,
|
|
1885
|
+
channel: session.channelSlug,
|
|
1886
|
+
thread_id: state.kandanThreadId,
|
|
1887
|
+
body: terminal.inputText,
|
|
1888
|
+
payload: {
|
|
1889
|
+
...localRunnerPayload(
|
|
1890
|
+
args.options,
|
|
1891
|
+
args.instanceId,
|
|
1892
|
+
"codex_output",
|
|
1893
|
+
state.codexThreadId,
|
|
1894
|
+
payloadContext,
|
|
1895
|
+
sourceMessageSeq,
|
|
1896
|
+
),
|
|
1897
|
+
...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
|
|
1898
|
+
structured: {
|
|
1899
|
+
kind: "codex_terminal_input",
|
|
1900
|
+
item_id: terminal.itemKey,
|
|
1901
|
+
transcript_unit_id: `codex_terminal_input:${terminal.itemKey}`,
|
|
1902
|
+
stream_state: "completed",
|
|
1903
|
+
process_id: terminal.processId ?? "",
|
|
1904
|
+
input_text: terminal.inputText,
|
|
1905
|
+
},
|
|
1906
|
+
},
|
|
1907
|
+
client_message_id: streamingClientMessageId(args.instanceId, {
|
|
1908
|
+
itemKey: `terminal:${terminal.itemKey}`,
|
|
1909
|
+
turnId,
|
|
1910
|
+
}),
|
|
1911
|
+
});
|
|
1912
|
+
rememberForwardedTerminalInputKey(state, terminal.itemKey);
|
|
1913
|
+
args.log("kandan.codex_output_forwarded", {
|
|
1914
|
+
turn_id: turnId,
|
|
1915
|
+
item_key: terminal.itemKey,
|
|
1916
|
+
structured_kind: "codex_terminal_input",
|
|
1917
|
+
command: null,
|
|
1918
|
+
file_paths: [],
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
async function forwardWebSearchProgress(
|
|
1923
|
+
args: ChannelSessionContext,
|
|
1924
|
+
state: ChannelSessionState,
|
|
1925
|
+
params: JsonObject,
|
|
1926
|
+
payloadContext: RunnerPayloadContext,
|
|
1927
|
+
): Promise<void> {
|
|
1928
|
+
if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const progress = codexWebSearchProgressFromNotification(params);
|
|
1933
|
+
|
|
1934
|
+
if (progress === undefined || progress.turnId === undefined) {
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const sourceMessageSeq = sourceMessageSeqForTurn(state, progress.turnId);
|
|
1939
|
+
|
|
1940
|
+
if (sourceMessageSeq === undefined) {
|
|
1941
|
+
args.log("codex.web_search_without_source_message", {
|
|
1942
|
+
turn_id: progress.turnId,
|
|
1943
|
+
item_key: progress.itemKey,
|
|
1944
|
+
});
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const itemKey = webSearchProgressItemKey(progress.turnId);
|
|
1949
|
+
const existing = findWebSearchProgressOutput(state, progress.turnId);
|
|
1950
|
+
const queries = mergeWebSearchQueries(existing?.queries ?? [], progress.queries);
|
|
1951
|
+
const body = webSearchProgressBody(queries);
|
|
1952
|
+
const structured = codexWebSearchStructuredMessage(itemKey, queries, "streaming");
|
|
1953
|
+
|
|
1954
|
+
if (existing === undefined) {
|
|
1955
|
+
const session = args.options.channelSession;
|
|
1956
|
+
const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
1957
|
+
workspace: session.workspaceSlug,
|
|
1958
|
+
channel: session.channelSlug,
|
|
1959
|
+
thread_id: state.kandanThreadId,
|
|
1960
|
+
body,
|
|
1961
|
+
payload: {
|
|
1962
|
+
...localRunnerPayload(
|
|
1963
|
+
args.options,
|
|
1964
|
+
args.instanceId,
|
|
1965
|
+
"codex_output",
|
|
1966
|
+
state.codexThreadId,
|
|
1967
|
+
payloadContext,
|
|
1968
|
+
sourceMessageSeq,
|
|
1969
|
+
),
|
|
1970
|
+
...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
|
|
1971
|
+
structured,
|
|
1972
|
+
},
|
|
1973
|
+
client_message_id: webSearchProgressClientMessageId(args.instanceId, progress.turnId),
|
|
1974
|
+
});
|
|
1975
|
+
const seq = integerValue(reply.seq);
|
|
1976
|
+
|
|
1977
|
+
if (seq !== undefined) {
|
|
1978
|
+
rememberWebSearchProgressOutput(state, {
|
|
1979
|
+
turnId: progress.turnId,
|
|
1980
|
+
itemKey,
|
|
1981
|
+
seq,
|
|
1982
|
+
queries,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
} else if (queries.length !== existing.queries.length) {
|
|
1986
|
+
await editCodexStructuredOutput(args, state, existing.seq, body, structured);
|
|
1987
|
+
rememberWebSearchProgressOutput(state, {
|
|
1988
|
+
...existing,
|
|
1989
|
+
queries,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
args.log("kandan.codex_web_search_progress_forwarded", {
|
|
1994
|
+
turn_id: progress.turnId,
|
|
1995
|
+
item_key: progress.itemKey,
|
|
1996
|
+
query_count: queries.length,
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
async function completeWebSearchProgress(
|
|
2001
|
+
args: ChannelSessionContext,
|
|
2002
|
+
state: ChannelSessionState,
|
|
2003
|
+
turnId: string,
|
|
2004
|
+
): Promise<void> {
|
|
2005
|
+
const existing = findWebSearchProgressOutput(state, turnId);
|
|
2006
|
+
|
|
2007
|
+
if (existing === undefined) {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
await editCodexStructuredOutput(
|
|
2012
|
+
args,
|
|
2013
|
+
state,
|
|
2014
|
+
existing.seq,
|
|
2015
|
+
webSearchProgressBody(existing.queries),
|
|
2016
|
+
codexWebSearchStructuredMessage(existing.itemKey, existing.queries, "completed"),
|
|
2017
|
+
);
|
|
2018
|
+
forgetWebSearchProgressOutput(state, turnId);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
async function completeFileChangeOutputs(
|
|
2022
|
+
args: ChannelSessionContext,
|
|
2023
|
+
state: ChannelSessionState,
|
|
2024
|
+
turnId: string,
|
|
2025
|
+
): Promise<void> {
|
|
2026
|
+
const outputs = state.streamingFileChangeOutputs.filter(
|
|
2027
|
+
output => output.turnId === turnId,
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
for (const output of outputs) {
|
|
2031
|
+
await editCodexStructuredOutput(
|
|
2032
|
+
args,
|
|
2033
|
+
state,
|
|
2034
|
+
output.seq,
|
|
2035
|
+
output.patchText,
|
|
2036
|
+
codexFileChangeStructuredMessage(
|
|
2037
|
+
output.itemKey,
|
|
2038
|
+
output.patchText,
|
|
2039
|
+
"completed",
|
|
2040
|
+
"completed",
|
|
2041
|
+
),
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function streamingClientMessageId(
|
|
2047
|
+
instanceId: string,
|
|
2048
|
+
delta: { readonly itemKey: string; readonly turnId: string | undefined },
|
|
2049
|
+
): string {
|
|
2050
|
+
const digest = createHash("sha256")
|
|
2051
|
+
.update(`${instanceId}:${delta.turnId ?? "turn"}:${delta.itemKey}`)
|
|
2052
|
+
.digest("hex")
|
|
2053
|
+
.slice(0, 32);
|
|
2054
|
+
|
|
2055
|
+
return `local-codex-stream-${digest}`;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function webSearchProgressClientMessageId(instanceId: string, turnId: string): string {
|
|
2059
|
+
const digest = createHash("sha256")
|
|
2060
|
+
.update(`${instanceId}:${turnId}:web-search`)
|
|
2061
|
+
.digest("hex")
|
|
2062
|
+
.slice(0, 32);
|
|
2063
|
+
|
|
2064
|
+
return `local-codex-search-${digest}`;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function webSearchProgressItemKey(turnId: string): string {
|
|
2068
|
+
return `web-search:${turnId}`;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function commandOutputBody(command: string, output: string): string {
|
|
2072
|
+
return [`$ ${command}`, output].filter(part => part.trim() !== "").join("\n\n");
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
async function editStreamedCodexOutput(
|
|
2076
|
+
args: ChannelSessionContext,
|
|
2077
|
+
state: ChannelSessionState,
|
|
2078
|
+
targetSeq: number,
|
|
2079
|
+
itemKey: string,
|
|
2080
|
+
content: string,
|
|
2081
|
+
streamState: "streaming" | "completed",
|
|
2082
|
+
): Promise<void> {
|
|
2083
|
+
await editCodexStructuredOutput(
|
|
2084
|
+
args,
|
|
2085
|
+
state,
|
|
2086
|
+
targetSeq,
|
|
2087
|
+
content,
|
|
2088
|
+
codexAssistantStructuredMessage(itemKey, content, streamState),
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
async function editCodexStructuredOutput(
|
|
2093
|
+
args: ChannelSessionContext,
|
|
2094
|
+
state: ChannelSessionState,
|
|
2095
|
+
targetSeq: number,
|
|
2096
|
+
content: string,
|
|
2097
|
+
structured: JsonObject,
|
|
2098
|
+
): Promise<void> {
|
|
2099
|
+
if (state.kandanThreadId === undefined) {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
const session = args.options.channelSession;
|
|
2104
|
+
await pushOk(args.kandan, args.topic, "session:edit_thread_message", {
|
|
2105
|
+
workspace: session.workspaceSlug,
|
|
2106
|
+
channel: session.channelSlug,
|
|
2107
|
+
thread_id: state.kandanThreadId,
|
|
2108
|
+
target_seq: targetSeq,
|
|
2109
|
+
body: content,
|
|
2110
|
+
structured,
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
async function mirrorLocalTuiInputFromNotification(
|
|
2115
|
+
args: ChannelSessionContext,
|
|
2116
|
+
state: ChannelSessionState,
|
|
2117
|
+
turnId: string,
|
|
2118
|
+
params: JsonObject,
|
|
2119
|
+
payloadContext: RunnerPayloadContext,
|
|
2120
|
+
): Promise<void> {
|
|
2121
|
+
if (!isLocalTuiTurn(state, turnId)) {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
const message = codexUserInputMessageFromNotification(params);
|
|
2126
|
+
|
|
2127
|
+
if (message === undefined) {
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
await mirrorLocalTuiInputMessage(args, state, turnId, message, payloadContext);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
async function mirrorLocalTuiInputMessage(
|
|
2135
|
+
args: ChannelSessionContext,
|
|
2136
|
+
state: ChannelSessionState,
|
|
2137
|
+
turnId: string,
|
|
2138
|
+
message: CodexUserInputMessage,
|
|
2139
|
+
payloadContext: RunnerPayloadContext,
|
|
2140
|
+
): Promise<void> {
|
|
2141
|
+
const logicalKey = tuiInputLogicalKey(turnId, message.body);
|
|
2142
|
+
|
|
2143
|
+
if (findMirroredTuiInputProjection(state, logicalKey) !== undefined) {
|
|
2144
|
+
rememberMirroredTuiInputItemKey(state, logicalKey, message.itemKey);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
ensureKandanThreadForLocalTuiTurn(state);
|
|
2149
|
+
const threadId = state.kandanThreadId;
|
|
2150
|
+
const codexThreadId = state.codexThreadId;
|
|
2151
|
+
|
|
2152
|
+
if (threadId === undefined || codexThreadId === undefined) {
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
const session = args.options.channelSession;
|
|
2157
|
+
const reply = await pushOk(args.kandan, args.topic, "session:post_thread_user_message", {
|
|
2158
|
+
workspace: session.workspaceSlug,
|
|
2159
|
+
channel: session.channelSlug,
|
|
2160
|
+
thread_id: threadId,
|
|
2161
|
+
body: message.body,
|
|
2162
|
+
payload: {
|
|
2163
|
+
...localRunnerPayload(
|
|
2164
|
+
args.options,
|
|
2165
|
+
args.instanceId,
|
|
2166
|
+
"tui_input",
|
|
2167
|
+
codexThreadId,
|
|
2168
|
+
payloadContext,
|
|
2169
|
+
),
|
|
2170
|
+
...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
|
|
2171
|
+
},
|
|
2172
|
+
client_message_id: tuiInputClientMessageId(args.instanceId, turnId, message.itemKey),
|
|
2173
|
+
});
|
|
2174
|
+
const seq = integerValue(reply.seq);
|
|
2175
|
+
if (seq !== undefined) {
|
|
2176
|
+
rememberTurnReplyTarget(state, turnId, seq);
|
|
2177
|
+
rememberMirroredTuiInputProjection(state, {
|
|
2178
|
+
logicalKey,
|
|
2179
|
+
turnId,
|
|
2180
|
+
seq,
|
|
2181
|
+
itemKeys: [message.itemKey],
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
args.log("kandan.tui_input_mirrored", {
|
|
2185
|
+
turn_id: turnId,
|
|
2186
|
+
item_key: message.itemKey,
|
|
2187
|
+
actor_user_id: payloadContext.runnerIdentity.actorUserId ?? null,
|
|
2188
|
+
actor_slug: payloadContext.runnerIdentity.actorUsername ?? null,
|
|
2189
|
+
});
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function tuiInputClientMessageId(
|
|
2193
|
+
instanceId: string,
|
|
2194
|
+
turnId: string,
|
|
2195
|
+
itemKey: string,
|
|
2196
|
+
): string {
|
|
2197
|
+
const digest = createHash("sha256")
|
|
2198
|
+
.update(`${instanceId}:${turnId}:${itemKey}`)
|
|
2199
|
+
.digest("hex")
|
|
2200
|
+
.slice(0, 32);
|
|
2201
|
+
|
|
2202
|
+
return `local-codex-tui-${digest}`;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function tuiInputLogicalKey(turnId: string, body: string): string {
|
|
2206
|
+
const digest = createHash("sha256")
|
|
2207
|
+
.update(`${turnId}:${body.trim()}`)
|
|
2208
|
+
.digest("hex")
|
|
2209
|
+
.slice(0, 32);
|
|
2210
|
+
|
|
2211
|
+
return `local-codex-tui-input:${digest}`;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function findMirroredTuiInputProjection(
|
|
2215
|
+
state: ChannelSessionState,
|
|
2216
|
+
logicalKey: string,
|
|
2217
|
+
): MirroredTuiInputProjection | undefined {
|
|
2218
|
+
return state.mirroredTuiInputProjections.find(
|
|
2219
|
+
projection => projection.logicalKey === logicalKey,
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function rememberMirroredTuiInputProjection(
|
|
2224
|
+
state: ChannelSessionState,
|
|
2225
|
+
projection: MirroredTuiInputProjection,
|
|
2226
|
+
): void {
|
|
2227
|
+
state.mirroredTuiInputProjections = [
|
|
2228
|
+
...state.mirroredTuiInputProjections.filter(
|
|
2229
|
+
existing => existing.logicalKey !== projection.logicalKey,
|
|
2230
|
+
),
|
|
2231
|
+
projection,
|
|
2232
|
+
].slice(-maxForwardedTurnIds);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function rememberMirroredTuiInputItemKey(
|
|
2236
|
+
state: ChannelSessionState,
|
|
2237
|
+
logicalKey: string,
|
|
2238
|
+
itemKey: string,
|
|
2239
|
+
): void {
|
|
2240
|
+
const existing = findMirroredTuiInputProjection(state, logicalKey);
|
|
2241
|
+
|
|
2242
|
+
if (existing === undefined || existing.itemKeys.includes(itemKey)) {
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
rememberMirroredTuiInputProjection(state, {
|
|
2247
|
+
...existing,
|
|
2248
|
+
itemKeys: [...existing.itemKeys, itemKey],
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
function findStreamingAssistantOutput(
|
|
2253
|
+
state: ChannelSessionState,
|
|
2254
|
+
itemKey: string,
|
|
2255
|
+
): StreamingAssistantOutput | undefined {
|
|
2256
|
+
return state.streamingAssistantOutputs.find(output => output.itemKey === itemKey);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function findStreamingReasoningOutput(
|
|
2260
|
+
state: ChannelSessionState,
|
|
2261
|
+
itemKey: string,
|
|
2262
|
+
): StreamingReasoningOutput | undefined {
|
|
2263
|
+
return state.streamingReasoningOutputs.find(output => output.itemKey === itemKey);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function findStreamingCommandOutput(
|
|
2267
|
+
state: ChannelSessionState,
|
|
2268
|
+
itemKey: string,
|
|
2269
|
+
): StreamingCommandOutput | undefined {
|
|
2270
|
+
return state.streamingCommandOutputs.find(output => output.itemKey === itemKey);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function findStreamingFileChangeOutput(
|
|
2274
|
+
state: ChannelSessionState,
|
|
2275
|
+
itemKey: string,
|
|
2276
|
+
): StreamingFileChangeOutput | undefined {
|
|
2277
|
+
return state.streamingFileChangeOutputs.find(output => output.itemKey === itemKey);
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
type StreamedStructuredOutput = {
|
|
2281
|
+
readonly itemKey: string;
|
|
2282
|
+
readonly seq: number;
|
|
2283
|
+
};
|
|
2284
|
+
|
|
2285
|
+
function resolveStreamingStructuredOutputForCompletedMessage(
|
|
2286
|
+
state: ChannelSessionState,
|
|
2287
|
+
itemKey: string,
|
|
2288
|
+
structured: JsonObject,
|
|
2289
|
+
): StreamedStructuredOutput | undefined {
|
|
2290
|
+
switch (stringValue(structured.kind)) {
|
|
2291
|
+
case "codex_reasoning": {
|
|
2292
|
+
const output = findStreamingReasoningOutput(state, itemKey);
|
|
2293
|
+
return output === undefined ? undefined : { itemKey: output.itemKey, seq: output.seq };
|
|
2294
|
+
}
|
|
2295
|
+
case "codex_command_execution": {
|
|
2296
|
+
const output = findStreamingCommandOutput(state, itemKey);
|
|
2297
|
+
return output === undefined ? undefined : { itemKey: output.itemKey, seq: output.seq };
|
|
2298
|
+
}
|
|
2299
|
+
case "codex_file_change": {
|
|
2300
|
+
const output = findStreamingFileChangeOutput(state, itemKey);
|
|
2301
|
+
return output === undefined ? undefined : { itemKey: output.itemKey, seq: output.seq };
|
|
2302
|
+
}
|
|
2303
|
+
default:
|
|
2304
|
+
return undefined;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function forgetStreamingStructuredOutput(
|
|
2309
|
+
state: ChannelSessionState,
|
|
2310
|
+
itemKey: string,
|
|
2311
|
+
structured: JsonObject,
|
|
2312
|
+
): void {
|
|
2313
|
+
switch (stringValue(structured.kind)) {
|
|
2314
|
+
case "codex_reasoning":
|
|
2315
|
+
forgetStreamingReasoningOutput(state, itemKey);
|
|
2316
|
+
break;
|
|
2317
|
+
case "codex_command_execution":
|
|
2318
|
+
forgetStreamingCommandOutput(state, itemKey);
|
|
2319
|
+
break;
|
|
2320
|
+
case "codex_file_change":
|
|
2321
|
+
forgetStreamingFileChangeOutput(state, itemKey);
|
|
2322
|
+
break;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
type StreamingAssistantOutputResolution =
|
|
2327
|
+
| { readonly status: "matched"; readonly output: StreamingAssistantOutput }
|
|
2328
|
+
| { readonly status: "none" }
|
|
2329
|
+
| { readonly status: "ambiguous"; readonly candidateCount: number };
|
|
2330
|
+
|
|
2331
|
+
class LogicalProjectionError extends Error {
|
|
2332
|
+
constructor(message: string) {
|
|
2333
|
+
super(message);
|
|
2334
|
+
this.name = "LogicalProjectionError";
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function resolveStreamingAssistantOutputForCompletedMessage(
|
|
2339
|
+
state: ChannelSessionState,
|
|
2340
|
+
turnId: string,
|
|
2341
|
+
itemKey: string,
|
|
2342
|
+
body: string,
|
|
2343
|
+
structured: JsonObject,
|
|
2344
|
+
): StreamingAssistantOutputResolution {
|
|
2345
|
+
const exact = findStreamingAssistantOutput(state, itemKey);
|
|
2346
|
+
|
|
2347
|
+
if (exact !== undefined) {
|
|
2348
|
+
return { status: "matched", output: exact };
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
if (stringValue(structured.kind) !== "codex_assistant_message") {
|
|
2352
|
+
return { status: "none" };
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const candidates = state.streamingAssistantOutputs.filter(
|
|
2356
|
+
output => output.turnId === turnId,
|
|
2357
|
+
);
|
|
2358
|
+
|
|
2359
|
+
switch (candidates.length) {
|
|
2360
|
+
case 0:
|
|
2361
|
+
return { status: "none" };
|
|
2362
|
+
case 1: {
|
|
2363
|
+
const [output] = candidates;
|
|
2364
|
+
return output === undefined
|
|
2365
|
+
? { status: "none" }
|
|
2366
|
+
: { status: "matched", output };
|
|
2367
|
+
}
|
|
2368
|
+
default: {
|
|
2369
|
+
const contentMatched = candidates.filter(
|
|
2370
|
+
output => normalizedTranscriptText(output.content) === normalizedTranscriptText(body),
|
|
2371
|
+
);
|
|
2372
|
+
|
|
2373
|
+
if (contentMatched.length === 1) {
|
|
2374
|
+
const [output] = contentMatched;
|
|
2375
|
+
return output === undefined
|
|
2376
|
+
? { status: "ambiguous", candidateCount: candidates.length }
|
|
2377
|
+
: { status: "matched", output };
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
return { status: "ambiguous", candidateCount: candidates.length };
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
function normalizedTranscriptText(value: string): string {
|
|
2386
|
+
return value.trim().replace(/\s+/g, " ");
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
function turnIsFinalizingOrForwarded(
|
|
2390
|
+
state: ChannelSessionState,
|
|
2391
|
+
turnId: string,
|
|
2392
|
+
): boolean {
|
|
2393
|
+
return (
|
|
2394
|
+
state.forwardingTurnIds.includes(turnId) ||
|
|
2395
|
+
state.forwardedTurnIds.includes(turnId)
|
|
2396
|
+
);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function rememberStreamingAssistantOutput(
|
|
2400
|
+
state: ChannelSessionState,
|
|
2401
|
+
output: StreamingAssistantOutput,
|
|
2402
|
+
): void {
|
|
2403
|
+
state.streamingAssistantOutputs = [
|
|
2404
|
+
...state.streamingAssistantOutputs.filter(existing => existing.itemKey !== output.itemKey),
|
|
2405
|
+
output,
|
|
2406
|
+
].slice(-maxForwardedTurnIds);
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function forgetStreamingAssistantOutput(
|
|
2410
|
+
state: ChannelSessionState,
|
|
2411
|
+
itemKey: string,
|
|
2412
|
+
): void {
|
|
2413
|
+
state.streamingAssistantOutputs = state.streamingAssistantOutputs.filter(
|
|
2414
|
+
output => output.itemKey !== itemKey,
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function rememberStreamingReasoningOutput(
|
|
2419
|
+
state: ChannelSessionState,
|
|
2420
|
+
output: StreamingReasoningOutput,
|
|
2421
|
+
): void {
|
|
2422
|
+
state.streamingReasoningOutputs = [
|
|
2423
|
+
...state.streamingReasoningOutputs.filter(existing => existing.itemKey !== output.itemKey),
|
|
2424
|
+
output,
|
|
2425
|
+
].slice(-maxForwardedTurnIds);
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function forgetStreamingReasoningOutput(
|
|
2429
|
+
state: ChannelSessionState,
|
|
2430
|
+
itemKey: string,
|
|
2431
|
+
): void {
|
|
2432
|
+
state.streamingReasoningOutputs = state.streamingReasoningOutputs.filter(
|
|
2433
|
+
output => output.itemKey !== itemKey,
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
function rememberStreamingCommandOutput(
|
|
2438
|
+
state: ChannelSessionState,
|
|
2439
|
+
output: StreamingCommandOutput,
|
|
2440
|
+
): void {
|
|
2441
|
+
state.streamingCommandOutputs = [
|
|
2442
|
+
...state.streamingCommandOutputs.filter(existing => existing.itemKey !== output.itemKey),
|
|
2443
|
+
output,
|
|
2444
|
+
].slice(-maxForwardedTurnIds);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
function forgetStreamingCommandOutput(
|
|
2448
|
+
state: ChannelSessionState,
|
|
2449
|
+
itemKey: string,
|
|
2450
|
+
): void {
|
|
2451
|
+
state.streamingCommandOutputs = state.streamingCommandOutputs.filter(
|
|
2452
|
+
output => output.itemKey !== itemKey,
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
function rememberStreamingFileChangeOutput(
|
|
2457
|
+
state: ChannelSessionState,
|
|
2458
|
+
output: StreamingFileChangeOutput,
|
|
2459
|
+
): void {
|
|
2460
|
+
state.streamingFileChangeOutputs = [
|
|
2461
|
+
...state.streamingFileChangeOutputs.filter(existing => existing.itemKey !== output.itemKey),
|
|
2462
|
+
output,
|
|
2463
|
+
].slice(-maxForwardedTurnIds);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function forgetStreamingFileChangeOutput(
|
|
2467
|
+
state: ChannelSessionState,
|
|
2468
|
+
itemKey: string,
|
|
2469
|
+
): void {
|
|
2470
|
+
state.streamingFileChangeOutputs = state.streamingFileChangeOutputs.filter(
|
|
2471
|
+
output => output.itemKey !== itemKey,
|
|
2472
|
+
);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function rememberForwardedTerminalInputKey(
|
|
2476
|
+
state: ChannelSessionState,
|
|
2477
|
+
itemKey: string,
|
|
2478
|
+
): void {
|
|
2479
|
+
if (state.forwardedTerminalInputKeys.includes(itemKey)) {
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
state.forwardedTerminalInputKeys = [...state.forwardedTerminalInputKeys, itemKey]
|
|
2484
|
+
.slice(-maxForwardedTurnIds);
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
function findWebSearchProgressOutput(
|
|
2488
|
+
state: ChannelSessionState,
|
|
2489
|
+
turnId: string,
|
|
2490
|
+
): WebSearchProgressOutput | undefined {
|
|
2491
|
+
return state.webSearchProgressOutputs.find(output => output.turnId === turnId);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function rememberWebSearchProgressOutput(
|
|
2495
|
+
state: ChannelSessionState,
|
|
2496
|
+
output: WebSearchProgressOutput,
|
|
2497
|
+
): void {
|
|
2498
|
+
state.webSearchProgressOutputs = [
|
|
2499
|
+
...state.webSearchProgressOutputs.filter(existing => existing.turnId !== output.turnId),
|
|
2500
|
+
output,
|
|
2501
|
+
].slice(-maxForwardedTurnIds);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function forgetWebSearchProgressOutput(
|
|
2505
|
+
state: ChannelSessionState,
|
|
2506
|
+
turnId: string,
|
|
2507
|
+
): void {
|
|
2508
|
+
state.webSearchProgressOutputs = state.webSearchProgressOutputs.filter(
|
|
2509
|
+
output => output.turnId !== turnId,
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function mergeWebSearchQueries(
|
|
2514
|
+
existing: readonly string[],
|
|
2515
|
+
incoming: readonly string[],
|
|
2516
|
+
): string[] {
|
|
2517
|
+
return [...existing, ...incoming].reduce<string[]>((acc, query) => {
|
|
2518
|
+
if (acc.includes(query)) {
|
|
2519
|
+
return acc;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
return [...acc, query];
|
|
2523
|
+
}, []);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
function codexNotificationTurnId(params: JsonObject): string | undefined {
|
|
2527
|
+
const turn = objectValue(params.turn);
|
|
2528
|
+
|
|
2529
|
+
return (
|
|
2530
|
+
stringValue(turn?.id) ??
|
|
2531
|
+
stringValue(params.turnId) ??
|
|
2532
|
+
stringValue(params.turn_id)
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
function codexNotificationThreadId(params: JsonObject): string | undefined {
|
|
2537
|
+
const thread = objectValue(params.thread);
|
|
2538
|
+
const turn = objectValue(params.turn);
|
|
2539
|
+
|
|
2540
|
+
return (
|
|
2541
|
+
stringValue(params.threadId) ??
|
|
2542
|
+
stringValue(params.thread_id) ??
|
|
2543
|
+
stringValue(thread?.id) ??
|
|
2544
|
+
stringValue(turn?.threadId) ??
|
|
2545
|
+
stringValue(turn?.thread_id)
|
|
2546
|
+
);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function rememberLocalTuiTurnIfNeeded(
|
|
2550
|
+
args: ChannelSessionContext,
|
|
2551
|
+
state: ChannelSessionState,
|
|
2552
|
+
threadId: string,
|
|
2553
|
+
turnId: string,
|
|
2554
|
+
): void {
|
|
2555
|
+
if (
|
|
2556
|
+
args.options.launchTui !== true ||
|
|
2557
|
+
state.codexThreadId !== threadId ||
|
|
2558
|
+
state.turn.status !== "idle"
|
|
2559
|
+
) {
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
state.localTuiTurnIds = rememberTurnId(state.localTuiTurnIds, turnId);
|
|
2564
|
+
if (state.kandanThreadId !== undefined) {
|
|
2565
|
+
startCodexTypingHeartbeat(args, state, state.kandanThreadId);
|
|
2566
|
+
}
|
|
2567
|
+
args.log("codex.tui_turn_started", { turn_id: turnId, codex_thread_id: threadId });
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function isLocalTuiTurn(state: ChannelSessionState, turnId: string): boolean {
|
|
2571
|
+
return state.localTuiTurnIds.includes(turnId);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function ensureKandanThreadForLocalTuiTurn(
|
|
2575
|
+
state: ChannelSessionState,
|
|
2576
|
+
): void {
|
|
2577
|
+
if (state.kandanThreadId !== undefined || state.rootSeq === undefined) {
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
state.kandanThreadId = randomUUID();
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function forgetLocalTuiTurnId(
|
|
2585
|
+
state: ChannelSessionState,
|
|
2586
|
+
turnId: string,
|
|
2587
|
+
): void {
|
|
2588
|
+
state.localTuiTurnIds = state.localTuiTurnIds.filter(id => id !== turnId);
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
function rememberPendingTuiInputMirror(
|
|
2592
|
+
state: ChannelSessionState,
|
|
2593
|
+
turnId: string,
|
|
2594
|
+
promise: Promise<void>,
|
|
2595
|
+
): void {
|
|
2596
|
+
state.pendingTuiInputMirrors = [
|
|
2597
|
+
...state.pendingTuiInputMirrors.filter(pending => pending.turnId !== turnId),
|
|
2598
|
+
{ turnId, promise },
|
|
2599
|
+
].slice(-maxForwardedTurnIds);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
function forgetPendingTuiInputMirror(
|
|
2603
|
+
state: ChannelSessionState,
|
|
2604
|
+
turnId: string,
|
|
2605
|
+
): void {
|
|
2606
|
+
state.pendingTuiInputMirrors = state.pendingTuiInputMirrors.filter(
|
|
2607
|
+
pending => pending.turnId !== turnId,
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
async function waitForPendingTuiInputMirror(
|
|
2612
|
+
state: ChannelSessionState,
|
|
2613
|
+
turnId: string,
|
|
2614
|
+
): Promise<void> {
|
|
2615
|
+
const pending = state.pendingTuiInputMirrors.find(
|
|
2616
|
+
mirror => mirror.turnId === turnId,
|
|
2617
|
+
);
|
|
2618
|
+
|
|
2619
|
+
if (pending !== undefined) {
|
|
2620
|
+
await pending.promise;
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
async function waitForStreamingForwardChains(
|
|
2625
|
+
state: ChannelSessionState,
|
|
2626
|
+
): Promise<void> {
|
|
2627
|
+
await Promise.all([
|
|
2628
|
+
state.assistantDeltaForwardChain.catch(() => undefined),
|
|
2629
|
+
state.reasoningDeltaForwardChain.catch(() => undefined),
|
|
2630
|
+
state.commandOutputForwardChain.catch(() => undefined),
|
|
2631
|
+
state.fileChangeForwardChain.catch(() => undefined),
|
|
2632
|
+
state.terminalInputForwardChain.catch(() => undefined),
|
|
2633
|
+
state.webSearchProgressForwardChain.catch(() => undefined),
|
|
2634
|
+
]);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
function rememberTurnReplyTarget(
|
|
2638
|
+
state: ChannelSessionState,
|
|
2639
|
+
turnId: string,
|
|
2640
|
+
replyToSeq: number,
|
|
2641
|
+
): void {
|
|
2642
|
+
state.turnReplyTargets = [
|
|
2643
|
+
...state.turnReplyTargets.filter(target => target.turnId !== turnId),
|
|
2644
|
+
{ turnId, replyToSeq },
|
|
2645
|
+
].slice(-maxForwardedTurnIds);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
function sourceMessageSeqForTurn(
|
|
2649
|
+
state: ChannelSessionState,
|
|
2650
|
+
turnId: string,
|
|
2651
|
+
): number | undefined {
|
|
2652
|
+
return state.turnReplyTargets.find(target => target.turnId === turnId)?.replyToSeq;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function fileChangePaths(structured: JsonObject): string[] {
|
|
2656
|
+
const changes = arrayValue(structured.changes) ?? [];
|
|
2657
|
+
|
|
2658
|
+
return changes
|
|
2659
|
+
.filter(isJsonObject)
|
|
2660
|
+
.map(change => stringValue(change.path) ?? "")
|
|
2661
|
+
.filter(path => path.trim() !== "");
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
async function postCodexThreadReboundMessage(
|
|
2665
|
+
args: ChannelSessionContext,
|
|
2666
|
+
state: ChannelSessionState,
|
|
2667
|
+
payloadContext: RunnerPayloadContext,
|
|
2668
|
+
oldCodexThreadId: string | undefined,
|
|
2669
|
+
newCodexThreadId: string,
|
|
2670
|
+
): Promise<void> {
|
|
2671
|
+
if (state.kandanThreadId === undefined) {
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
const session = args.options.channelSession;
|
|
2676
|
+
const body = [
|
|
2677
|
+
"Codex reconnected.",
|
|
2678
|
+
"",
|
|
2679
|
+
"The previous local Codex app-server thread was not available in this process, so this runner started a new local Codex thread for this Kandan thread.",
|
|
2680
|
+
"",
|
|
2681
|
+
`Previous Codex thread: ${oldCodexThreadId ?? "unknown"}`,
|
|
2682
|
+
`New Codex thread: ${newCodexThreadId}`,
|
|
2683
|
+
].join("\n");
|
|
2684
|
+
|
|
2685
|
+
await pushOk(args.kandan, args.topic, "session:post_thread_message", {
|
|
2686
|
+
workspace: session.workspaceSlug,
|
|
2687
|
+
channel: session.channelSlug,
|
|
2688
|
+
thread_id: state.kandanThreadId,
|
|
2689
|
+
body,
|
|
2690
|
+
payload: localRunnerPayload(
|
|
2691
|
+
args.options,
|
|
2692
|
+
args.instanceId,
|
|
2693
|
+
"availability",
|
|
2694
|
+
newCodexThreadId,
|
|
2695
|
+
payloadContext,
|
|
2696
|
+
),
|
|
2697
|
+
client_message_id: `local-codex-rebound-${args.instanceId}-${newCodexThreadId}`,
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function isRecoverableCodexThreadError(error: unknown): boolean {
|
|
2702
|
+
if (!(error instanceof Error)) {
|
|
2703
|
+
return false;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
return (
|
|
2707
|
+
error.message.includes("turn/start failed: thread not found") ||
|
|
2708
|
+
error.message.includes("turn/start failed: invalid thread id")
|
|
2709
|
+
);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function turnCanForward(state: ChannelSessionState, turnId: string): boolean {
|
|
2713
|
+
if (state.retryableTurnIds.includes(turnId)) {
|
|
2714
|
+
return true;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
if (isLocalTuiTurn(state, turnId)) {
|
|
2718
|
+
return true;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
switch (state.turn.status) {
|
|
2722
|
+
case "active":
|
|
2723
|
+
return state.turn.turnId === turnId;
|
|
2724
|
+
case "completing":
|
|
2725
|
+
return state.turn.turnId === turnId;
|
|
2726
|
+
case "idle":
|
|
2727
|
+
case "starting":
|
|
2728
|
+
return false;
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function rememberForwardedTurnId(
|
|
2733
|
+
state: ChannelSessionState,
|
|
2734
|
+
turnId: string,
|
|
2735
|
+
): void {
|
|
2736
|
+
if (state.forwardedTurnIds.includes(turnId)) {
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
state.forwardedTurnIds = rememberTurnId(state.forwardedTurnIds, turnId);
|
|
2741
|
+
forgetRetryableTurnId(state, turnId);
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
function rememberForwardingTurnId(
|
|
2745
|
+
state: ChannelSessionState,
|
|
2746
|
+
turnId: string,
|
|
2747
|
+
): void {
|
|
2748
|
+
state.forwardingTurnIds = rememberTurnId(state.forwardingTurnIds, turnId);
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function forgetForwardingTurnId(
|
|
2752
|
+
state: ChannelSessionState,
|
|
2753
|
+
turnId: string,
|
|
2754
|
+
): void {
|
|
2755
|
+
state.forwardingTurnIds = state.forwardingTurnIds.filter(id => id !== turnId);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function rememberRetryableTurnId(
|
|
2759
|
+
state: ChannelSessionState,
|
|
2760
|
+
turnId: string,
|
|
2761
|
+
): void {
|
|
2762
|
+
state.retryableTurnIds = rememberTurnId(state.retryableTurnIds, turnId);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function forgetRetryableTurnId(
|
|
2766
|
+
state: ChannelSessionState,
|
|
2767
|
+
turnId: string,
|
|
2768
|
+
): void {
|
|
2769
|
+
state.retryableTurnIds = state.retryableTurnIds.filter(id => id !== turnId);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function rememberTurnId(
|
|
2773
|
+
values: readonly string[],
|
|
2774
|
+
turnId: string,
|
|
2775
|
+
): string[] {
|
|
2776
|
+
if (values.includes(turnId)) {
|
|
2777
|
+
return [...values];
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
return [...values, turnId].slice(-maxForwardedTurnIds);
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
async function stopCodexTyping(
|
|
2784
|
+
args: ChannelSessionContext,
|
|
2785
|
+
state: ChannelSessionState,
|
|
2786
|
+
): Promise<void> {
|
|
2787
|
+
stopCodexTypingHeartbeat(state);
|
|
2788
|
+
|
|
2789
|
+
if (state.kandanThreadId === undefined) {
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
const session = args.options.channelSession;
|
|
2794
|
+
await pushOptional(
|
|
2795
|
+
args.kandan,
|
|
2796
|
+
args.topic,
|
|
2797
|
+
"session:typing_stop",
|
|
2798
|
+
{
|
|
2799
|
+
workspace: session.workspaceSlug,
|
|
2800
|
+
channel: session.channelSlug,
|
|
2801
|
+
thread_id: state.kandanThreadId,
|
|
2802
|
+
},
|
|
2803
|
+
args.log,
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function startCodexTypingHeartbeat(
|
|
2808
|
+
args: ChannelSessionContext,
|
|
2809
|
+
state: ChannelSessionState,
|
|
2810
|
+
threadId: string,
|
|
2811
|
+
): void {
|
|
2812
|
+
const send = () => {
|
|
2813
|
+
if (state.typingHeartbeatInFlight) {
|
|
2814
|
+
return;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const session = args.options.channelSession;
|
|
2818
|
+
state.typingHeartbeatInFlight = true;
|
|
2819
|
+
void pushOptional(
|
|
2820
|
+
args.kandan,
|
|
2821
|
+
args.topic,
|
|
2822
|
+
"session:typing_start",
|
|
2823
|
+
{
|
|
2824
|
+
workspace: session.workspaceSlug,
|
|
2825
|
+
channel: session.channelSlug,
|
|
2826
|
+
thread_id: threadId,
|
|
2827
|
+
},
|
|
2828
|
+
args.log,
|
|
2829
|
+
)
|
|
2830
|
+
.then(() => refreshActiveProcessingHeartbeat(args, state))
|
|
2831
|
+
.catch(error => {
|
|
2832
|
+
args.log("kandan.typing_heartbeat_failed", {
|
|
2833
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2834
|
+
});
|
|
2835
|
+
})
|
|
2836
|
+
.finally(() => {
|
|
2837
|
+
state.typingHeartbeatInFlight = false;
|
|
2838
|
+
});
|
|
2839
|
+
};
|
|
2840
|
+
|
|
2841
|
+
send();
|
|
2842
|
+
|
|
2843
|
+
if (state.typingHeartbeat === undefined) {
|
|
2844
|
+
state.typingHeartbeat = setInterval(send, codexTypingHeartbeatMs);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
function stopCodexTypingHeartbeat(state: ChannelSessionState): void {
|
|
2849
|
+
if (state.typingHeartbeat !== undefined) {
|
|
2850
|
+
clearInterval(state.typingHeartbeat);
|
|
2851
|
+
state.typingHeartbeat = undefined;
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
type LocalCodexProcessingReason =
|
|
2856
|
+
| "starting turn"
|
|
2857
|
+
| "streaming response"
|
|
2858
|
+
| "running terminal command"
|
|
2859
|
+
| "interrupt requested"
|
|
2860
|
+
| "awaiting approval";
|
|
2861
|
+
|
|
2862
|
+
type LocalCodexMessageState =
|
|
2863
|
+
| { readonly status: "queued" }
|
|
2864
|
+
| { readonly status: "processed" }
|
|
2865
|
+
| {
|
|
2866
|
+
readonly status: "processing";
|
|
2867
|
+
readonly reason: LocalCodexProcessingReason;
|
|
2868
|
+
readonly approval?: CodexApprovalMessageState | undefined;
|
|
2869
|
+
}
|
|
2870
|
+
| { readonly status: "ignored"; readonly reason: string }
|
|
2871
|
+
| { readonly status: "failed"; readonly reason: string };
|
|
2872
|
+
|
|
2873
|
+
async function publishKandanMessageState(
|
|
2874
|
+
args: ChannelSessionContext,
|
|
2875
|
+
event: KandanChatEvent,
|
|
2876
|
+
state: LocalCodexMessageState,
|
|
2877
|
+
): Promise<void> {
|
|
2878
|
+
if (event.threadId === undefined) {
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
await publishMessageState(
|
|
2883
|
+
args,
|
|
2884
|
+
event.threadId,
|
|
2885
|
+
event.seq,
|
|
2886
|
+
state,
|
|
2887
|
+
event.actorSlug,
|
|
2888
|
+
event.actorUserId,
|
|
2889
|
+
);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
async function publishQueuedMessageState(
|
|
2893
|
+
args: ChannelSessionContext,
|
|
2894
|
+
state: ChannelSessionState,
|
|
2895
|
+
message: QueuedKandanMessage,
|
|
2896
|
+
messageState: LocalCodexMessageState,
|
|
2897
|
+
): Promise<void> {
|
|
2898
|
+
if (state.kandanThreadId === undefined) {
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
await publishMessageState(
|
|
2903
|
+
args,
|
|
2904
|
+
state.kandanThreadId,
|
|
2905
|
+
message.seq,
|
|
2906
|
+
messageState,
|
|
2907
|
+
message.actorSlug,
|
|
2908
|
+
message.actorUserId,
|
|
2909
|
+
);
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
async function publishMessageState(
|
|
2913
|
+
args: ChannelSessionContext,
|
|
2914
|
+
threadId: string,
|
|
2915
|
+
seq: number,
|
|
2916
|
+
state: LocalCodexMessageState,
|
|
2917
|
+
actorSlug?: string,
|
|
2918
|
+
actorUserId?: number,
|
|
2919
|
+
): Promise<void> {
|
|
2920
|
+
const session = args.options.channelSession;
|
|
2921
|
+
const payload = {
|
|
2922
|
+
workspace: session.workspaceSlug,
|
|
2923
|
+
channel: session.channelSlug,
|
|
2924
|
+
thread_id: threadId,
|
|
2925
|
+
seq,
|
|
2926
|
+
status: state.status,
|
|
2927
|
+
...("reason" in state ? { reason: state.reason } : {}),
|
|
2928
|
+
...(state.status === "processing" && state.approval !== undefined
|
|
2929
|
+
? {
|
|
2930
|
+
approval_request_id: state.approval.requestId,
|
|
2931
|
+
approval_kind: state.approval.kind,
|
|
2932
|
+
approval_summary: state.approval.summary,
|
|
2933
|
+
}
|
|
2934
|
+
: {}),
|
|
2935
|
+
...(actorSlug === undefined ? {} : { actor_slug: actorSlug }),
|
|
2936
|
+
...(actorUserId === undefined ? {} : { actor_user_id: actorUserId }),
|
|
2937
|
+
};
|
|
2938
|
+
|
|
2939
|
+
await pushOptional(args.kandan, args.topic, "message_state", payload, args.log);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function processingReasonForCodexNotification(
|
|
2943
|
+
method: string,
|
|
2944
|
+
params: JsonObject,
|
|
2945
|
+
): Exclude<LocalCodexProcessingReason, "awaiting approval"> | undefined {
|
|
2946
|
+
if (method === "item/agentMessage/delta" || method === "item/reasoning/textDelta") {
|
|
2947
|
+
return "streaming response";
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
if (
|
|
2951
|
+
method.startsWith("item/commandExecution/") ||
|
|
2952
|
+
method === "command/exec/outputDelta"
|
|
2953
|
+
) {
|
|
2954
|
+
return "running terminal command";
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
const item = objectValue(params.item) ?? params;
|
|
2958
|
+
const itemType = stringValue(item.type);
|
|
2959
|
+
|
|
2960
|
+
switch (itemType) {
|
|
2961
|
+
case "commandExecution":
|
|
2962
|
+
case "terminalInput":
|
|
2963
|
+
return "running terminal command";
|
|
2964
|
+
case "agentMessage":
|
|
2965
|
+
case "reasoning":
|
|
2966
|
+
case "fileChange":
|
|
2967
|
+
case "web_search_call":
|
|
2968
|
+
case "webSearchCall":
|
|
2969
|
+
case "webSearch":
|
|
2970
|
+
return "streaming response";
|
|
2971
|
+
default:
|
|
2972
|
+
return undefined;
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
function activeTurnId(state: ChannelSessionState): string | undefined {
|
|
2977
|
+
switch (state.turn.status) {
|
|
2978
|
+
case "active":
|
|
2979
|
+
case "completing":
|
|
2980
|
+
return state.turn.turnId;
|
|
2981
|
+
case "idle":
|
|
2982
|
+
case "starting":
|
|
2983
|
+
return undefined;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
function abortReason(params: JsonObject): string {
|
|
2988
|
+
return (
|
|
2989
|
+
stringValue(params.reason) ??
|
|
2990
|
+
stringValue(params.message) ??
|
|
2991
|
+
stringValue(params.error) ??
|
|
2992
|
+
"codex_turn_aborted"
|
|
2993
|
+
);
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
async function failActiveCodexTurn(
|
|
2997
|
+
args: ChannelSessionContext,
|
|
2998
|
+
state: ChannelSessionState,
|
|
2999
|
+
turnId: string,
|
|
3000
|
+
reason: string,
|
|
3001
|
+
payloadContext: RunnerPayloadContext,
|
|
3002
|
+
): Promise<void> {
|
|
3003
|
+
rejectPendingApprovalRequestsForTurn(state, turnId, new Error(reason));
|
|
3004
|
+
const seq = activeQueuedSeqForTurn(state, turnId);
|
|
3005
|
+
|
|
3006
|
+
if (seq !== undefined && state.kandanThreadId !== undefined) {
|
|
3007
|
+
await publishMessageState(args, state.kandanThreadId, seq, {
|
|
3008
|
+
status: "failed",
|
|
3009
|
+
reason,
|
|
3010
|
+
});
|
|
3011
|
+
clearActiveProcessingState(state, seq);
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
forgetLocalTuiTurnId(state, turnId);
|
|
3015
|
+
rememberForwardedTurnId(state, turnId);
|
|
3016
|
+
|
|
3017
|
+
if (
|
|
3018
|
+
state.turn.status !== "idle" &&
|
|
3019
|
+
(state.turn.status === "starting" || state.turn.turnId === turnId)
|
|
3020
|
+
) {
|
|
3021
|
+
state.turn = { status: "idle" };
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
await stopCodexTyping(args, state);
|
|
3025
|
+
await drainKandanMessageQueue(args, state, payloadContext);
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
function activeQueuedSeqForTurn(
|
|
3029
|
+
state: ChannelSessionState,
|
|
3030
|
+
turnId: string,
|
|
3031
|
+
): number | undefined {
|
|
3032
|
+
switch (state.turn.status) {
|
|
3033
|
+
case "active":
|
|
3034
|
+
case "completing":
|
|
3035
|
+
return state.turn.turnId === turnId ? state.turn.queuedSeq : undefined;
|
|
3036
|
+
case "idle":
|
|
3037
|
+
case "starting":
|
|
3038
|
+
return undefined;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
async function refreshActiveProcessingState(
|
|
3043
|
+
args: ChannelSessionContext,
|
|
3044
|
+
state: ChannelSessionState,
|
|
3045
|
+
turnId: string,
|
|
3046
|
+
reason: Exclude<LocalCodexProcessingReason, "awaiting approval">,
|
|
3047
|
+
): Promise<void> {
|
|
3048
|
+
const seq = activeQueuedSeqForTurn(state, turnId);
|
|
3049
|
+
|
|
3050
|
+
if (seq === undefined || state.kandanThreadId === undefined) {
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
if (
|
|
3055
|
+
state.activeProcessingState?.seq === seq &&
|
|
3056
|
+
state.activeProcessingState.reason === reason
|
|
3057
|
+
) {
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
state.activeProcessingState = { seq, reason };
|
|
3062
|
+
await publishMessageState(args, state.kandanThreadId, seq, {
|
|
3063
|
+
status: "processing",
|
|
3064
|
+
reason,
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
async function refreshActiveProcessingHeartbeat(
|
|
3069
|
+
args: ChannelSessionContext,
|
|
3070
|
+
state: ChannelSessionState,
|
|
3071
|
+
): Promise<void> {
|
|
3072
|
+
const activeProcessingState = state.activeProcessingState;
|
|
3073
|
+
|
|
3074
|
+
if (
|
|
3075
|
+
activeProcessingState === undefined ||
|
|
3076
|
+
state.kandanThreadId === undefined
|
|
3077
|
+
) {
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
await publishMessageState(
|
|
3082
|
+
args,
|
|
3083
|
+
state.kandanThreadId,
|
|
3084
|
+
activeProcessingState.seq,
|
|
3085
|
+
processingMessageStateFromActive(activeProcessingState),
|
|
3086
|
+
);
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
function clearActiveProcessingState(state: ChannelSessionState, seq: number): void {
|
|
3090
|
+
if (state.activeProcessingState?.seq === seq) {
|
|
3091
|
+
state.activeProcessingState = undefined;
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function processingMessageStateFromActive(
|
|
3096
|
+
state: ActiveProcessingState,
|
|
3097
|
+
): Extract<LocalCodexMessageState, { readonly status: "processing" }> {
|
|
3098
|
+
switch (state.reason) {
|
|
3099
|
+
case "awaiting approval":
|
|
3100
|
+
return { status: "processing", reason: state.reason, approval: state.approval };
|
|
3101
|
+
case "starting turn":
|
|
3102
|
+
case "streaming response":
|
|
3103
|
+
case "running terminal command":
|
|
3104
|
+
case "interrupt requested":
|
|
3105
|
+
return { status: "processing", reason: state.reason };
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function extractThreadIdFromResponse(response: JsonRpcResponse): string {
|
|
3110
|
+
if ("error" in response) {
|
|
3111
|
+
throw new Error(`thread/start failed: ${response.error.message}`);
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
const threadId = stringValue(
|
|
3115
|
+
objectValue(objectValue(response.result)?.thread)?.id,
|
|
3116
|
+
);
|
|
3117
|
+
|
|
3118
|
+
if (threadId === undefined) {
|
|
3119
|
+
throw new Error("thread/start response did not include thread.id");
|
|
3120
|
+
}
|
|
3121
|
+
|
|
3122
|
+
return threadId;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
function extractTurnIdFromResponse(response: JsonRpcResponse): string {
|
|
3126
|
+
if ("error" in response) {
|
|
3127
|
+
throw new Error(`turn/start failed: ${response.error.message}`);
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
const turnId = stringValue(
|
|
3131
|
+
objectValue(objectValue(response.result)?.turn)?.id,
|
|
3132
|
+
);
|
|
3133
|
+
|
|
3134
|
+
if (turnId === undefined) {
|
|
3135
|
+
throw new Error("turn/start response did not include turn.id");
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
return turnId;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
async function startCodexThread(
|
|
3142
|
+
codex: CodexAppServerClient,
|
|
3143
|
+
options: ChannelSessionRunnerOptions,
|
|
3144
|
+
): Promise<string> {
|
|
3145
|
+
const start = await codex.request("thread/start", {
|
|
3146
|
+
cwd: options.cwd,
|
|
3147
|
+
serviceName: "kandan-local-runner",
|
|
3148
|
+
personality: "pragmatic",
|
|
3149
|
+
...codexThreadRuntimeOverrides(options),
|
|
3150
|
+
});
|
|
3151
|
+
|
|
3152
|
+
return extractThreadIdFromResponse(start);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
function codexThreadRuntimeOverrides(options: ChannelSessionRunnerOptions): JsonObject {
|
|
3156
|
+
const session = options.channelSession;
|
|
3157
|
+
|
|
3158
|
+
return {
|
|
3159
|
+
...(session.model === undefined ? {} : { model: session.model }),
|
|
3160
|
+
...(session.reasoningEffort === undefined
|
|
3161
|
+
? {}
|
|
3162
|
+
: { reasoningEffort: session.reasoningEffort }),
|
|
3163
|
+
...(options.fast === true ? { serviceTier: "fast" } : {}),
|
|
3164
|
+
...(session.approvalPolicy === undefined
|
|
3165
|
+
? {}
|
|
3166
|
+
: { approvalPolicy: session.approvalPolicy }),
|
|
3167
|
+
...(session.sandbox === undefined ? {} : { sandbox: session.sandbox }),
|
|
3168
|
+
};
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
function codexTurnRuntimeOverrides(options: ChannelSessionRunnerOptions): JsonObject {
|
|
3172
|
+
const session = options.channelSession;
|
|
3173
|
+
|
|
3174
|
+
return {
|
|
3175
|
+
cwd: options.cwd,
|
|
3176
|
+
...(session.model === undefined ? {} : { model: session.model }),
|
|
3177
|
+
...(session.reasoningEffort === undefined ? {} : { effort: session.reasoningEffort }),
|
|
3178
|
+
...(options.fast === true ? { serviceTier: "fast" } : {}),
|
|
3179
|
+
...(session.approvalPolicy === undefined
|
|
3180
|
+
? {}
|
|
3181
|
+
: { approvalPolicy: session.approvalPolicy }),
|
|
3182
|
+
...(session.sandbox === undefined
|
|
3183
|
+
? {}
|
|
3184
|
+
: { sandboxPolicy: codexSandboxPolicy(session.sandbox, options.cwd) }),
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
function codexSandboxPolicy(sandbox: string, cwd: string): JsonObject {
|
|
3189
|
+
switch (sandbox) {
|
|
3190
|
+
case "danger-full-access":
|
|
3191
|
+
return { type: "dangerFullAccess" };
|
|
3192
|
+
case "read-only":
|
|
3193
|
+
return {
|
|
3194
|
+
type: "readOnly",
|
|
3195
|
+
access: { type: "fullAccess" },
|
|
3196
|
+
networkAccess: false,
|
|
3197
|
+
};
|
|
3198
|
+
case "workspace-write":
|
|
3199
|
+
return {
|
|
3200
|
+
type: "workspaceWrite",
|
|
3201
|
+
writableRoots: [cwd],
|
|
3202
|
+
readOnlyAccess: { type: "fullAccess" },
|
|
3203
|
+
networkAccess: false,
|
|
3204
|
+
excludeTmpdirEnvVar: false,
|
|
3205
|
+
excludeSlashTmp: false,
|
|
3206
|
+
};
|
|
3207
|
+
default:
|
|
3208
|
+
throw new Error(`unsupported Codex sandbox mode: ${sandbox}`);
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
async function pushOk(
|
|
3213
|
+
kandan: PhoenixClient,
|
|
3214
|
+
topic: string,
|
|
3215
|
+
event: string,
|
|
3216
|
+
payload: JsonObject,
|
|
3217
|
+
): Promise<JsonObject> {
|
|
3218
|
+
const reply = await kandan.push(topic, event, payload);
|
|
3219
|
+
|
|
3220
|
+
if (
|
|
3221
|
+
isJsonObject(reply) &&
|
|
3222
|
+
reply.status === "ok" &&
|
|
3223
|
+
isJsonObject(reply.response)
|
|
3224
|
+
) {
|
|
3225
|
+
return reply.response;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
throw new Error(
|
|
3229
|
+
`kandan push failed: ${event}: ${JSON.stringify(reply).slice(0, 500)}`,
|
|
3230
|
+
);
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
async function pushOptional(
|
|
3234
|
+
kandan: PhoenixClient,
|
|
3235
|
+
topic: string,
|
|
3236
|
+
event: string,
|
|
3237
|
+
payload: JsonObject,
|
|
3238
|
+
log: RunnerLogger,
|
|
3239
|
+
): Promise<void> {
|
|
3240
|
+
try {
|
|
3241
|
+
await pushOk(kandan, topic, event, payload);
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
log("kandan.optional_push_failed", {
|
|
3244
|
+
event,
|
|
3245
|
+
message: error instanceof Error ? error.message : String(error),
|
|
3246
|
+
});
|
|
3247
|
+
}
|
|
3248
|
+
}
|