@makerbi/remodex 2.0.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/remodex.js +1 -1
- package/package.json +2 -2
- package/src/account-status.js +5 -4
- package/src/bridge.js +1809 -689
- package/src/codex-desktop-refresher.js +35 -7
- package/src/cursor-acp-client.js +242 -0
- package/src/cursor-models.js +134 -0
- package/src/cursor-provider.js +1197 -0
- package/src/desktop-ipc-action-follower.js +2323 -123
- package/src/desktop-ipc-conversation-adapter.js +1132 -0
- package/src/desktop-ipc-conversation-projector.js +1169 -0
- package/src/desktop-ipc-live-owner.js +1790 -0
- package/src/desktop-ipc-owner-transport.js +750 -0
- package/src/desktop-ipc-shared.js +473 -0
- package/src/desktop-ipc-state-patches.js +218 -0
- package/src/opencode-models.js +108 -0
- package/src/opencode-provider.js +1151 -0
- package/src/project-handler.js +50 -7
- package/src/project-registry.js +466 -0
- package/src/push-notification-tracker.js +4 -4
- package/src/rollout-live-mirror.js +946 -78
- package/src/rollout-turn-semantics.js +20 -0
- package/src/runtime-provider-models.js +164 -0
- package/src/runtime-provider-router.js +365 -0
- package/src/scripts/codex-refresh.applescript +26 -15
- package/src/secure-transport.js +204 -9
- package/src/session-jsonl-history.js +429 -39
- package/src/thread-context-handler.js +8 -6
- package/src/thread-runtime-settings-store.js +247 -0
- package/src/voice-audio.js +344 -0
- package/src/voice-handler.js +363 -173
|
@@ -13,11 +13,38 @@ const {
|
|
|
13
13
|
} = require("./rollout-watch");
|
|
14
14
|
const { resolveCodexGeneratedImagesRoot } = require("./codex-home");
|
|
15
15
|
const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const {
|
|
17
|
+
TERMINAL_TASK_EVENT_TYPES,
|
|
18
|
+
terminalEventClosesTrackedTurn,
|
|
19
|
+
} = require("./rollout-turn-semantics");
|
|
20
|
+
const {
|
|
21
|
+
hasVisiblePlanUpdate,
|
|
22
|
+
buildRemodexSourceItemKey,
|
|
23
|
+
visibleUserPromptFromInputEntries,
|
|
24
|
+
visibleUserPromptText,
|
|
25
|
+
responseItemMessageText,
|
|
26
|
+
} = require("./desktop-ipc-shared");
|
|
27
|
+
|
|
28
|
+
// The phone batches each poll tick's notifications and settles its timeline
|
|
29
|
+
// ~80ms after the batch ends (CodexService liveMirrorBatchFlushNanoseconds).
|
|
30
|
+
// Keep this interval comfortably above that settle window, or lower both
|
|
31
|
+
// together, so consecutive ticks never merge into one batch.
|
|
32
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
18
33
|
const DEFAULT_LOOKUP_TIMEOUT_MS = 5_000;
|
|
19
34
|
const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
|
|
20
35
|
const DEFAULT_ACTIVITY_HEARTBEAT_MS = 5_000;
|
|
36
|
+
// Bootstrap replay must not resurrect runs whose rollout stopped growing long ago
|
|
37
|
+
// (aborted/killed desktop runs never write task_complete).
|
|
38
|
+
const DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS = 10 * 60_000;
|
|
39
|
+
const DEFAULT_SYNTHETIC_TERMINAL_GRACE_MS = 1_000;
|
|
40
|
+
// Rollouts can be tens of megabytes. They are a live-delta fallback, not the
|
|
41
|
+
// durable conversation history, so bootstrap must never synchronously parse
|
|
42
|
+
// the entire file just to discover an active turn.
|
|
43
|
+
const DEFAULT_BOOTSTRAP_METADATA_HEAD_BYTES = 256 * 1024;
|
|
44
|
+
const DEFAULT_BOOTSTRAP_TAIL_BYTES = 4 * 1024 * 1024;
|
|
45
|
+
// Keep a hard bound, but match the JSONL history reader's 64MB recovery window
|
|
46
|
+
// so a long active turn does not degrade permanently to no live context.
|
|
47
|
+
const DEFAULT_BOOTSTRAP_MAX_BYTES = 64 * 1024 * 1024;
|
|
21
48
|
const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
|
|
22
49
|
|
|
23
50
|
// Observes desktop-authored rollout files and replays the currently active run as
|
|
@@ -33,11 +60,17 @@ function createRolloutLiveMirrorController({
|
|
|
33
60
|
lookupTimeoutMs = DEFAULT_LOOKUP_TIMEOUT_MS,
|
|
34
61
|
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
35
62
|
activityHeartbeatMs = DEFAULT_ACTIVITY_HEARTBEAT_MS,
|
|
63
|
+
staleActiveRunMaxAgeMs = DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS,
|
|
64
|
+
syntheticTerminalGraceMs = DEFAULT_SYNTHETIC_TERMINAL_GRACE_MS,
|
|
65
|
+
// Rollout tailing is the fallback mirror; when another live source already
|
|
66
|
+
// streams a thread (IPC follower state or bridge-owned app-server stream),
|
|
67
|
+
// emitting from the file too would double every row on the phone.
|
|
68
|
+
shouldSuppressThread = null,
|
|
36
69
|
} = {}) {
|
|
37
70
|
const mirrorsByThreadId = new Map();
|
|
38
71
|
|
|
39
|
-
function observeInbound(rawMessage) {
|
|
40
|
-
const request = safeParseJSON(rawMessage);
|
|
72
|
+
function observeInbound(rawMessage, parsedMessage = null) {
|
|
73
|
+
const request = parsedMessage ?? safeParseJSON(rawMessage);
|
|
41
74
|
const method = readString(request?.method);
|
|
42
75
|
if (!DESKTOP_RESUME_METHODS.has(method)) {
|
|
43
76
|
return;
|
|
@@ -55,9 +88,23 @@ function createRolloutLiveMirrorController({
|
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
let mirror;
|
|
91
|
+
let suppressionContext = {};
|
|
92
|
+
const isThreadSuppressed = () => Boolean(shouldSuppressThread?.(threadId, suppressionContext));
|
|
58
93
|
mirror = createThreadRolloutLiveMirror({
|
|
59
94
|
threadId,
|
|
60
|
-
sendApplicationResponse
|
|
95
|
+
sendApplicationResponse: typeof shouldSuppressThread === "function"
|
|
96
|
+
? (rawNotification) => {
|
|
97
|
+
if (!isThreadSuppressed()) {
|
|
98
|
+
sendApplicationResponse(rawNotification);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
: sendApplicationResponse,
|
|
102
|
+
isSuppressed: typeof shouldSuppressThread === "function"
|
|
103
|
+
? (context) => {
|
|
104
|
+
suppressionContext = context || {};
|
|
105
|
+
return isThreadSuppressed();
|
|
106
|
+
}
|
|
107
|
+
: () => false,
|
|
61
108
|
logPrefix,
|
|
62
109
|
fsModule,
|
|
63
110
|
now,
|
|
@@ -67,6 +114,8 @@ function createRolloutLiveMirrorController({
|
|
|
67
114
|
lookupTimeoutMs,
|
|
68
115
|
idleTimeoutMs,
|
|
69
116
|
activityHeartbeatMs,
|
|
117
|
+
staleActiveRunMaxAgeMs,
|
|
118
|
+
syntheticTerminalGraceMs,
|
|
70
119
|
onStop() {
|
|
71
120
|
if (mirrorsByThreadId.get(threadId) === mirror) {
|
|
72
121
|
mirrorsByThreadId.delete(threadId);
|
|
@@ -94,6 +143,7 @@ function createRolloutLiveMirrorController({
|
|
|
94
143
|
function createThreadRolloutLiveMirror({
|
|
95
144
|
threadId,
|
|
96
145
|
sendApplicationResponse,
|
|
146
|
+
isSuppressed = () => false,
|
|
97
147
|
logPrefix,
|
|
98
148
|
fsModule,
|
|
99
149
|
now,
|
|
@@ -103,6 +153,8 @@ function createThreadRolloutLiveMirror({
|
|
|
103
153
|
lookupTimeoutMs,
|
|
104
154
|
idleTimeoutMs,
|
|
105
155
|
activityHeartbeatMs,
|
|
156
|
+
staleActiveRunMaxAgeMs,
|
|
157
|
+
syntheticTerminalGraceMs,
|
|
106
158
|
onStop = () => {},
|
|
107
159
|
}) {
|
|
108
160
|
const startedAt = now();
|
|
@@ -113,8 +165,13 @@ function createThreadRolloutLiveMirror({
|
|
|
113
165
|
let lastSize = 0;
|
|
114
166
|
let partialLine = "";
|
|
115
167
|
let lastActivityAt = startedAt;
|
|
168
|
+
// Rollout growth only: heartbeats deliberately never refresh this clock, so a
|
|
169
|
+
// desktop process that died mid-run (no terminal event, file frozen) cannot
|
|
170
|
+
// keep the mirror heartbeating "running" forever.
|
|
171
|
+
let lastGrowthAt = startedAt;
|
|
116
172
|
let lastHeartbeatAt = 0;
|
|
117
173
|
let didBootstrap = false;
|
|
174
|
+
let wasSuppressed = false;
|
|
118
175
|
|
|
119
176
|
const intervalId = setIntervalFn(tick, pollIntervalMs);
|
|
120
177
|
tick();
|
|
@@ -142,7 +199,22 @@ function createThreadRolloutLiveMirror({
|
|
|
142
199
|
}
|
|
143
200
|
}
|
|
144
201
|
|
|
145
|
-
const
|
|
202
|
+
const rolloutStat = fsModule.statSync(rolloutPath);
|
|
203
|
+
const fileSize = rolloutStat.size;
|
|
204
|
+
// While another live source streams this thread the tail keeps consuming
|
|
205
|
+
// rollout lines with its emissions muted. Compare per-thread activity so
|
|
206
|
+
// a quiet Desktop turn stays owned, while newer rollout growth can recover
|
|
207
|
+
// from a stale connected snapshot.
|
|
208
|
+
const suppressed = isSuppressed({
|
|
209
|
+
fallbackActivityAt: Number(rolloutStat.mtimeMs) || 0,
|
|
210
|
+
});
|
|
211
|
+
if (wasSuppressed && !suppressed && didBootstrap) {
|
|
212
|
+
lastSize = 0;
|
|
213
|
+
partialLine = "";
|
|
214
|
+
didBootstrap = false;
|
|
215
|
+
resetRunState(state);
|
|
216
|
+
}
|
|
217
|
+
wasSuppressed = suppressed;
|
|
146
218
|
if (!didBootstrap) {
|
|
147
219
|
didBootstrap = true;
|
|
148
220
|
bootstrapFromExistingRollout({
|
|
@@ -151,9 +223,12 @@ function createThreadRolloutLiveMirror({
|
|
|
151
223
|
state,
|
|
152
224
|
fsModule,
|
|
153
225
|
sendApplicationResponse,
|
|
226
|
+
nowMs: currentTime,
|
|
227
|
+
staleActiveRunMaxAgeMs,
|
|
154
228
|
});
|
|
155
229
|
lastSize = fileSize;
|
|
156
230
|
lastActivityAt = currentTime;
|
|
231
|
+
lastGrowthAt = currentTime;
|
|
157
232
|
lastHeartbeatAt = currentTime;
|
|
158
233
|
if (state.isDesktopOrigin === false) {
|
|
159
234
|
stop();
|
|
@@ -161,10 +236,28 @@ function createThreadRolloutLiveMirror({
|
|
|
161
236
|
return;
|
|
162
237
|
}
|
|
163
238
|
|
|
239
|
+
if (fileSize < lastSize) {
|
|
240
|
+
// Rollout files can be rewritten/truncated by desktop recovery. The
|
|
241
|
+
// rewritten contents are a different history, not live growth: reset the
|
|
242
|
+
// cursor and re-run the bootstrap path (tagged catch-up / terminal
|
|
243
|
+
// catch-up) instead of replaying the whole file as untagged live events.
|
|
244
|
+
lastSize = 0;
|
|
245
|
+
partialLine = "";
|
|
246
|
+
didBootstrap = false;
|
|
247
|
+
resetRunState(state);
|
|
248
|
+
lastGrowthAt = currentTime;
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
164
252
|
if (fileSize > lastSize) {
|
|
253
|
+
// A capped bootstrap has no verified active-turn opener. Never append
|
|
254
|
+
// arbitrary deltas to that unknown state: wait for growth, then retry
|
|
255
|
+
// a bounded coherent bootstrap. A new task_started+prompt near EOF
|
|
256
|
+
// recovers immediately; an old huge run remains canonical-history only.
|
|
165
257
|
const chunk = readFileSlice(rolloutPath, lastSize, fileSize, fsModule);
|
|
166
258
|
lastSize = fileSize;
|
|
167
259
|
lastActivityAt = currentTime;
|
|
260
|
+
lastGrowthAt = currentTime;
|
|
168
261
|
lastHeartbeatAt = currentTime;
|
|
169
262
|
if (!chunk) {
|
|
170
263
|
return;
|
|
@@ -179,16 +272,51 @@ function createThreadRolloutLiveMirror({
|
|
|
179
272
|
searchStart = nlIndex + 1;
|
|
180
273
|
}
|
|
181
274
|
partialLine = searchStart < combined.length ? combined.substring(searchStart) : "";
|
|
182
|
-
|
|
275
|
+
if (state.awaitingCoherentBoundary) {
|
|
276
|
+
if (processAwaitingCoherentBoundary(lines, state, sendApplicationResponse, currentTime)) {
|
|
277
|
+
state.awaitingCoherentBoundary = false;
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// Real growth proves the run is alive again; resume normal mirroring.
|
|
282
|
+
state.suppressLiveActivityUntilGrowth = false;
|
|
283
|
+
processRolloutLines(lines, state, sendApplicationResponse, { nowMs: currentTime });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const syntheticTerminalNotifications = finalizePendingSyntheticTerminalIfReady(
|
|
288
|
+
state,
|
|
289
|
+
currentTime,
|
|
290
|
+
syntheticTerminalGraceMs
|
|
291
|
+
);
|
|
292
|
+
if (syntheticTerminalNotifications.length > 0) {
|
|
293
|
+
for (const notification of syntheticTerminalNotifications) {
|
|
294
|
+
sendApplicationResponse(JSON.stringify(notification));
|
|
295
|
+
}
|
|
296
|
+
lastActivityAt = currentTime;
|
|
297
|
+
lastHeartbeatAt = currentTime;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A frozen rollout with a still-open turn means the desktop process died
|
|
302
|
+
// mid-run (crash / kill: no terminal event will ever arrive). Stop before
|
|
303
|
+
// heartbeating so the phone is not kept in "running" forever.
|
|
304
|
+
if (state.activeTurnId && currentTime - lastGrowthAt >= staleActiveRunMaxAgeMs) {
|
|
305
|
+
stop();
|
|
183
306
|
return;
|
|
184
307
|
}
|
|
185
308
|
|
|
186
309
|
if (
|
|
187
310
|
state.isDesktopOrigin !== false
|
|
188
311
|
&& state.activeTurnId
|
|
312
|
+
&& !state.suppressLiveActivityUntilGrowth
|
|
189
313
|
&& currentTime - lastHeartbeatAt >= activityHeartbeatMs
|
|
190
314
|
) {
|
|
191
315
|
lastHeartbeatAt = currentTime;
|
|
316
|
+
// Heartbeats keep the idle timeout from killing a quiet-but-alive run
|
|
317
|
+
// (long thinking stretches legitimately exceed the 60s idle window);
|
|
318
|
+
// the growth-stale guard above still bounds crashed runs.
|
|
319
|
+
lastActivityAt = currentTime;
|
|
192
320
|
sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
|
|
193
321
|
threadId: state.threadId,
|
|
194
322
|
turnId: state.activeTurnId,
|
|
@@ -214,8 +342,19 @@ function createThreadRolloutLiveMirror({
|
|
|
214
342
|
return;
|
|
215
343
|
}
|
|
216
344
|
|
|
345
|
+
// Mark stopped and clear the interval first: a throwing send during the
|
|
346
|
+
// final partial-line flush must never leak the poll interval.
|
|
217
347
|
isStopped = true;
|
|
218
348
|
clearIntervalFn(intervalId);
|
|
349
|
+
if (partialLine) {
|
|
350
|
+
const flushLine = partialLine;
|
|
351
|
+
partialLine = "";
|
|
352
|
+
try {
|
|
353
|
+
processRolloutLines([flushLine], state, sendApplicationResponse, { nowMs: now() });
|
|
354
|
+
} catch (error) {
|
|
355
|
+
console.warn(`${logPrefix} rollout live mirror final flush failed for ${threadId}: ${error.message}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
219
358
|
onStop();
|
|
220
359
|
}
|
|
221
360
|
|
|
@@ -231,8 +370,55 @@ function bootstrapFromExistingRollout({
|
|
|
231
370
|
state,
|
|
232
371
|
fsModule,
|
|
233
372
|
sendApplicationResponse,
|
|
373
|
+
nowMs = Date.now(),
|
|
374
|
+
staleActiveRunMaxAgeMs = DEFAULT_STALE_ACTIVE_RUN_MAX_AGE_MS,
|
|
234
375
|
}) {
|
|
235
|
-
|
|
376
|
+
// Read metadata independently from the tail. session_meta is written at the
|
|
377
|
+
// beginning, while the active run lives at the end. This keeps reopening a
|
|
378
|
+
// 30MB rollout bounded and avoids treating a partial tail as history.
|
|
379
|
+
const metadataContents = readFileSlice(
|
|
380
|
+
rolloutPath,
|
|
381
|
+
0,
|
|
382
|
+
Math.min(fileSize, DEFAULT_BOOTSTRAP_METADATA_HEAD_BYTES),
|
|
383
|
+
fsModule
|
|
384
|
+
);
|
|
385
|
+
for (const rawLine of metadataContents.split("\n")) {
|
|
386
|
+
const parsed = safeParseJSON(rawLine.trim());
|
|
387
|
+
if (parsed?.type === "session_meta") {
|
|
388
|
+
populateSessionMetaState(state, parsed.payload);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (!isDesktopRolloutOrigin(state.sessionMeta)) {
|
|
393
|
+
state.isDesktopOrigin = false;
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
state.isDesktopOrigin = true;
|
|
397
|
+
|
|
398
|
+
const bootstrapWindow = readCoherentBootstrapWindow({
|
|
399
|
+
rolloutPath,
|
|
400
|
+
fileSize,
|
|
401
|
+
fsModule,
|
|
402
|
+
});
|
|
403
|
+
if (!bootstrapWindow) {
|
|
404
|
+
// The active run starts outside the bounded bootstrap window. Do not emit
|
|
405
|
+
// a plausible-looking tail: canonical history remains the baseline and
|
|
406
|
+
// this mirror will still consume future growth normally.
|
|
407
|
+
state.awaitingCoherentBoundary = true;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const { tailStart, contents: bootstrapContents } = bootstrapWindow;
|
|
411
|
+
let initialContents = bootstrapContents;
|
|
412
|
+
if (!initialContents) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// The first bytes may be the end of a JSON record. Drop that fragment rather
|
|
416
|
+
// than guessing, because an incomplete task_started record would lose the
|
|
417
|
+
// user opener and recreate the exact tail-only regression we are fixing.
|
|
418
|
+
if (tailStart > 0) {
|
|
419
|
+
const firstNewline = initialContents.indexOf("\n");
|
|
420
|
+
initialContents = firstNewline >= 0 ? initialContents.slice(firstNewline + 1) : "";
|
|
421
|
+
}
|
|
236
422
|
if (!initialContents) {
|
|
237
423
|
return;
|
|
238
424
|
}
|
|
@@ -242,6 +428,7 @@ function bootstrapFromExistingRollout({
|
|
|
242
428
|
let insideActiveRun = false;
|
|
243
429
|
let activeTurnId = null;
|
|
244
430
|
let pendingUserPreludeLine = null;
|
|
431
|
+
let latestTerminalRun = null;
|
|
245
432
|
|
|
246
433
|
for (const rawLine of lines) {
|
|
247
434
|
const line = rawLine.trim();
|
|
@@ -254,14 +441,20 @@ function bootstrapFromExistingRollout({
|
|
|
254
441
|
continue;
|
|
255
442
|
}
|
|
256
443
|
|
|
257
|
-
if (parsed.type === "session_meta") {
|
|
258
|
-
populateSessionMetaState(state, parsed.payload);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
444
|
const taskEventType = parsed?.type === "event_msg"
|
|
262
445
|
? readString(parsed?.payload?.type)
|
|
263
446
|
: "";
|
|
264
|
-
|
|
447
|
+
const eventUserMessage = taskEventType === "user_message"
|
|
448
|
+
&& Boolean(visibleUserPromptFromInputEntries(
|
|
449
|
+
readString(parsed?.payload?.message) || readString(parsed?.payload?.text)
|
|
450
|
+
));
|
|
451
|
+
const responseUserMessage = parsed?.type === "response_item"
|
|
452
|
+
&& readString(parsed?.payload?.role).toLowerCase() === "user"
|
|
453
|
+
&& Boolean(
|
|
454
|
+
visibleUserPromptFromInputEntries(extractResponseItemMessageText(parsed?.payload || {}))
|
|
455
|
+
|| responseItemHasUserImage(parsed?.payload)
|
|
456
|
+
);
|
|
457
|
+
if (eventUserMessage || responseUserMessage) {
|
|
265
458
|
pendingUserPreludeLine = line;
|
|
266
459
|
}
|
|
267
460
|
if (taskEventType === "task_started") {
|
|
@@ -269,6 +462,7 @@ function bootstrapFromExistingRollout({
|
|
|
269
462
|
activeTurnId = readString(parsed?.payload?.turn_id)
|
|
270
463
|
|| readString(parsed?.payload?.turnId)
|
|
271
464
|
|| "";
|
|
465
|
+
latestTerminalRun = null;
|
|
272
466
|
activeRunLines.length = 0;
|
|
273
467
|
if (pendingUserPreludeLine) {
|
|
274
468
|
activeRunLines.push(pendingUserPreludeLine);
|
|
@@ -282,28 +476,290 @@ function bootstrapFromExistingRollout({
|
|
|
282
476
|
}
|
|
283
477
|
|
|
284
478
|
activeRunLines.push(line);
|
|
285
|
-
if (taskEventType
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
479
|
+
if (TERMINAL_TASK_EVENT_TYPES.has(taskEventType)) {
|
|
480
|
+
// A sibling parallel turn's terminal event must not close the newest
|
|
481
|
+
// run's window; its own terminal event is still honored later.
|
|
482
|
+
const terminalTurnId = readString(parsed?.payload?.turn_id)
|
|
483
|
+
|| readString(parsed?.payload?.turnId);
|
|
484
|
+
if (terminalEventClosesTrackedTurn(terminalTurnId, activeTurnId)) {
|
|
485
|
+
latestTerminalRun = terminalRunFromEvent(parsed, activeTurnId);
|
|
486
|
+
insideActiveRun = false;
|
|
487
|
+
activeTurnId = "";
|
|
488
|
+
activeRunLines.length = 0;
|
|
489
|
+
pendingUserPreludeLine = null;
|
|
490
|
+
}
|
|
290
491
|
}
|
|
291
492
|
}
|
|
292
493
|
|
|
293
|
-
if (
|
|
294
|
-
state.
|
|
494
|
+
if (activeRunLines.length === 0 && latestTerminalRun) {
|
|
495
|
+
sendApplicationResponse(JSON.stringify(terminalCatchUpNotification(state.threadId, latestTerminalRun)));
|
|
295
496
|
return;
|
|
296
497
|
}
|
|
297
498
|
|
|
298
|
-
|
|
299
|
-
|
|
499
|
+
// A run with no terminal marker whose rollout stopped growing long ago is dead
|
|
500
|
+
// (killed process / lost session); replaying it would fake a live stream and
|
|
501
|
+
// pin the reopened thread in "running" forever. Hydrate the run context
|
|
502
|
+
// silently instead, so heartbeats stay off but a run that resumes writing can
|
|
503
|
+
// still mirror its new activity live.
|
|
504
|
+
if (
|
|
505
|
+
activeRunLines.length > 0
|
|
506
|
+
&& isRolloutFileStale(rolloutPath, fsModule, nowMs, staleActiveRunMaxAgeMs)
|
|
507
|
+
) {
|
|
508
|
+
processRolloutLines(activeRunLines, state, () => {});
|
|
509
|
+
// task_started resets per-run state while hydrating. Apply the stale-run
|
|
510
|
+
// suppression afterwards so it survives until real file growth proves the
|
|
511
|
+
// desktop process is alive again.
|
|
512
|
+
state.suppressLiveActivityUntilGrowth = true;
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Bootstrap replay is catch-up history, not live streaming: tag it so the
|
|
517
|
+
// phone can batch-apply it, then close the burst with an explicit marker so
|
|
518
|
+
// the run still reads as active without waiting for the next heartbeat.
|
|
519
|
+
processRolloutLines(activeRunLines, state, sendApplicationResponse, {
|
|
520
|
+
tagBootstrapReplay: true,
|
|
521
|
+
});
|
|
522
|
+
if (activeRunLines.length > 0 && state.activeTurnId) {
|
|
523
|
+
sendApplicationResponse(JSON.stringify(createNotification("turn/activity", {
|
|
524
|
+
threadId: state.threadId,
|
|
525
|
+
turnId: state.activeTurnId,
|
|
526
|
+
id: state.activeTurnId,
|
|
527
|
+
remodexRolloutBootstrapComplete: true,
|
|
528
|
+
})));
|
|
529
|
+
}
|
|
300
530
|
}
|
|
301
531
|
|
|
302
|
-
|
|
532
|
+
// Expands backwards only until the newest active task has its opening user
|
|
533
|
+
// message. Every expansion reads just the newly needed prefix, so a 30MB file
|
|
534
|
+
// is read at most once rather than once per retry. The hard cap keeps bootstrap
|
|
535
|
+
// work/memory bounded; no coherent opener means no replay.
|
|
536
|
+
function readCoherentBootstrapWindow({ rolloutPath, fileSize, fsModule }) {
|
|
537
|
+
const maxBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_MAX_BYTES);
|
|
538
|
+
let windowBytes = Math.min(fileSize, DEFAULT_BOOTSTRAP_TAIL_BYTES);
|
|
539
|
+
let tailStart = Math.max(0, fileSize - windowBytes);
|
|
540
|
+
let contents = readFileSlice(rolloutPath, tailStart, fileSize, fsModule);
|
|
541
|
+
if (!contents) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
while (true) {
|
|
546
|
+
const alignedContents = alignedBootstrapContents(contents, tailStart);
|
|
547
|
+
const boundary = inspectBootstrapRunBoundary(alignedContents);
|
|
548
|
+
// When the window already reaches byte zero it is the complete rollout:
|
|
549
|
+
// some legitimate system/continuation turns have no materialized user row.
|
|
550
|
+
// The opener requirement only protects a truncated tail.
|
|
551
|
+
if (!boundary.hasActiveRun || boundary.hasOpeningUser || tailStart === 0) {
|
|
552
|
+
return { tailStart, contents };
|
|
553
|
+
}
|
|
554
|
+
if (windowBytes >= maxBytes || tailStart === 0) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const nextWindowBytes = Math.min(maxBytes, windowBytes * 2);
|
|
559
|
+
const nextTailStart = Math.max(0, fileSize - nextWindowBytes);
|
|
560
|
+
const prefix = readFileSlice(rolloutPath, nextTailStart, tailStart, fsModule);
|
|
561
|
+
if (!prefix) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
contents = `${prefix}${contents}`;
|
|
565
|
+
windowBytes = nextWindowBytes;
|
|
566
|
+
tailStart = nextTailStart;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function alignedBootstrapContents(contents, tailStart) {
|
|
571
|
+
if (tailStart === 0) {
|
|
572
|
+
return contents;
|
|
573
|
+
}
|
|
574
|
+
const firstNewline = contents.indexOf("\n");
|
|
575
|
+
return firstNewline >= 0 ? contents.slice(firstNewline + 1) : "";
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function inspectBootstrapRunBoundary(contents) {
|
|
579
|
+
let activeTurnId = "";
|
|
580
|
+
let hasOpeningUser = false;
|
|
581
|
+
let hasTurnOutputSinceStart = false;
|
|
582
|
+
let pendingUserBeforeStart = false;
|
|
583
|
+
// A tail can begin after task_started. In that case activity without a
|
|
584
|
+
// closing terminal is evidence of an unknown active boundary, not permission
|
|
585
|
+
// to replay a partial conversation.
|
|
586
|
+
let unboundedActivitySinceTerminal = false;
|
|
587
|
+
|
|
588
|
+
for (const rawLine of contents.split("\n")) {
|
|
589
|
+
const parsed = safeParseJSON(rawLine.trim());
|
|
590
|
+
if (!parsed) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const taskEventType = parsed?.type === "event_msg"
|
|
594
|
+
? readString(parsed?.payload?.type)
|
|
595
|
+
: "";
|
|
596
|
+
const isUser = taskEventType === "user_message"
|
|
597
|
+
|| (parsed?.type === "response_item" && readString(parsed?.payload?.role).toLowerCase() === "user");
|
|
598
|
+
const isResponseUser = parsed?.type === "response_item"
|
|
599
|
+
&& readString(parsed?.payload?.role).toLowerCase() === "user";
|
|
600
|
+
const userText = isResponseUser
|
|
601
|
+
? extractResponseItemMessageText(parsed?.payload || {})
|
|
602
|
+
: firstNonEmptyString([readString(parsed?.payload?.message), readString(parsed?.payload?.text)]);
|
|
603
|
+
const isVisibleUser = isUser && Boolean(
|
|
604
|
+
visibleUserPromptText(userText).trim()
|
|
605
|
+
|| (isResponseUser && responseItemHasUserImage(parsed?.payload))
|
|
606
|
+
);
|
|
607
|
+
if (taskEventType === "task_started") {
|
|
608
|
+
activeTurnId = readString(parsed?.payload?.turn_id)
|
|
609
|
+
|| readString(parsed?.payload?.turnId)
|
|
610
|
+
|| "synthetic-active-turn";
|
|
611
|
+
hasOpeningUser = pendingUserBeforeStart;
|
|
612
|
+
hasTurnOutputSinceStart = false;
|
|
613
|
+
pendingUserBeforeStart = false;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (!activeTurnId) {
|
|
617
|
+
const isNeutral = isBootstrapNeutralRecord(parsed, taskEventType)
|
|
618
|
+
|| (isUser && !isVisibleUser);
|
|
619
|
+
if (TERMINAL_TASK_EVENT_TYPES.has(taskEventType)) {
|
|
620
|
+
unboundedActivitySinceTerminal = false;
|
|
621
|
+
} else if (!isNeutral) {
|
|
622
|
+
unboundedActivitySinceTerminal = true;
|
|
623
|
+
}
|
|
624
|
+
if (isVisibleUser) {
|
|
625
|
+
pendingUserBeforeStart = true;
|
|
626
|
+
} else if (!isNeutral) {
|
|
627
|
+
pendingUserBeforeStart = false;
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// The only user item that can certify a truncated active run is the one
|
|
632
|
+
// adjacent to task_started, before any assistant/tool output. Later user
|
|
633
|
+
// messages are steering/follow-up input and must never turn a partial tail
|
|
634
|
+
// into a valid bootstrap baseline.
|
|
635
|
+
if (isVisibleUser && !hasTurnOutputSinceStart) {
|
|
636
|
+
hasOpeningUser = true;
|
|
637
|
+
}
|
|
638
|
+
if (TERMINAL_TASK_EVENT_TYPES.has(taskEventType)) {
|
|
639
|
+
const terminalTurnId = readString(parsed?.payload?.turn_id)
|
|
640
|
+
|| readString(parsed?.payload?.turnId);
|
|
641
|
+
if (terminalEventClosesTrackedTurn(terminalTurnId, activeTurnId)) {
|
|
642
|
+
activeTurnId = "";
|
|
643
|
+
hasOpeningUser = false;
|
|
644
|
+
hasTurnOutputSinceStart = false;
|
|
645
|
+
pendingUserBeforeStart = false;
|
|
646
|
+
unboundedActivitySinceTerminal = false;
|
|
647
|
+
}
|
|
648
|
+
} else if (!isUser && !isBootstrapNeutralRecord(parsed, taskEventType)) {
|
|
649
|
+
hasTurnOutputSinceStart = true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
hasActiveRun: Boolean(activeTurnId) || unboundedActivitySinceTerminal,
|
|
655
|
+
hasOpeningUser,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// These records describe the runtime envelope around a turn. They are neither
|
|
660
|
+
// visible assistant output nor tool activity, so they must not turn the first
|
|
661
|
+
// real user prompt into a later steer during a bounded bootstrap scan.
|
|
662
|
+
function isBootstrapNeutralRecord(entry, taskEventType = "") {
|
|
663
|
+
const entryType = readString(entry?.type).toLowerCase();
|
|
664
|
+
return entryType === "session_meta"
|
|
665
|
+
|| entryType === "world_state"
|
|
666
|
+
|| entryType === "turn_context"
|
|
667
|
+
|| taskEventType === "context_updated";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// After a bounded bootstrap cannot reach the old opener, consume only new
|
|
671
|
+
// bytes. A later real user+task_started boundary safely starts a new live run;
|
|
672
|
+
// everything before it remains canonical-history territory.
|
|
673
|
+
function processAwaitingCoherentBoundary(lines, state, sendApplicationResponse, nowMs) {
|
|
674
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
675
|
+
const rawLine = lines[index];
|
|
676
|
+
const line = rawLine.trim();
|
|
677
|
+
const parsed = safeParseJSON(line);
|
|
678
|
+
if (!parsed) continue;
|
|
679
|
+
const eventType = parsed?.type === "event_msg" ? readString(parsed?.payload?.type) : "";
|
|
680
|
+
const responseUser = parsed?.type === "response_item"
|
|
681
|
+
&& readString(parsed?.payload?.role).toLowerCase() === "user";
|
|
682
|
+
const visibleUser = eventType === "user_message"
|
|
683
|
+
? Boolean(visibleUserPromptFromInputEntries(readString(parsed?.payload?.message) || readString(parsed?.payload?.text)))
|
|
684
|
+
: responseUser && Boolean(visibleUserPromptFromInputEntries(extractResponseItemMessageText(parsed?.payload || {})) || responseItemHasUserImage(parsed?.payload));
|
|
685
|
+
if (visibleUser) state.awaitingBoundaryPreludeLine = line;
|
|
686
|
+
if (eventType !== "task_started" || !state.awaitingBoundaryPreludeLine) continue;
|
|
687
|
+
const boundaryLines = [state.awaitingBoundaryPreludeLine, line];
|
|
688
|
+
state.awaitingBoundaryPreludeLine = "";
|
|
689
|
+
resetRunState(state);
|
|
690
|
+
processRolloutLines(boundaryLines, state, sendApplicationResponse, { nowMs });
|
|
691
|
+
// The boundary and its first output frequently land in the same filesystem
|
|
692
|
+
// read. Replay the remainder immediately so recovery never drops that
|
|
693
|
+
// assistant/tool burst while changing modes.
|
|
694
|
+
processRolloutLines(lines.slice(index + 1), state, sendApplicationResponse, { nowMs });
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function isRolloutFileStale(rolloutPath, fsModule, nowMs, staleActiveRunMaxAgeMs) {
|
|
701
|
+
try {
|
|
702
|
+
const modifiedAtMs = fsModule.statSync(rolloutPath).mtimeMs;
|
|
703
|
+
return Number.isFinite(modifiedAtMs) && nowMs - modifiedAtMs >= staleActiveRunMaxAgeMs;
|
|
704
|
+
} catch {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function terminalRunFromEvent(entry, fallbackTurnId = "") {
|
|
710
|
+
const payload = entry?.payload || {};
|
|
711
|
+
const eventType = readString(payload.type);
|
|
712
|
+
if (!TERMINAL_TASK_EVENT_TYPES.has(eventType)) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const turnId = readString(payload.turn_id)
|
|
717
|
+
|| readString(payload.turnId)
|
|
718
|
+
|| readString(fallbackTurnId);
|
|
719
|
+
if (!turnId) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
eventType,
|
|
725
|
+
turnId,
|
|
726
|
+
message: readString(payload.message),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function terminalCatchUpNotification(threadId, terminalRun) {
|
|
731
|
+
const params = {
|
|
732
|
+
threadId,
|
|
733
|
+
turnId: terminalRun.turnId,
|
|
734
|
+
id: terminalRun.turnId,
|
|
735
|
+
remodexRolloutTerminalCatchUp: true,
|
|
736
|
+
};
|
|
737
|
+
if (terminalRun.eventType === "turn_aborted") {
|
|
738
|
+
params.status = "aborted";
|
|
739
|
+
} else if (terminalRun.eventType === "error") {
|
|
740
|
+
params.status = "failed";
|
|
741
|
+
if (terminalRun.message) {
|
|
742
|
+
params.error = { message: terminalRun.message };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return createNotification("turn/completed", params);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function processRolloutLines(lines, state, sendApplicationResponse, {
|
|
749
|
+
tagBootstrapReplay = false,
|
|
750
|
+
nowMs = Date.now(),
|
|
751
|
+
} = {}) {
|
|
303
752
|
if (!Array.isArray(lines) || lines.length === 0) {
|
|
304
753
|
return;
|
|
305
754
|
}
|
|
306
755
|
|
|
756
|
+
const emitNotification = (notification) => {
|
|
757
|
+
if (tagBootstrapReplay && notification.params && typeof notification.params === "object") {
|
|
758
|
+
notification.params.remodexRolloutBootstrapReplay = true;
|
|
759
|
+
}
|
|
760
|
+
sendApplicationResponse(JSON.stringify(notification));
|
|
761
|
+
};
|
|
762
|
+
|
|
307
763
|
for (const rawLine of lines) {
|
|
308
764
|
const line = rawLine.trim();
|
|
309
765
|
if (!line) {
|
|
@@ -315,14 +771,14 @@ function processRolloutLines(lines, state, sendApplicationResponse) {
|
|
|
315
771
|
continue;
|
|
316
772
|
}
|
|
317
773
|
|
|
318
|
-
const notifications = synthesizeNotificationsFromRolloutEntry(parsed, state);
|
|
774
|
+
const notifications = synthesizeNotificationsFromRolloutEntry(parsed, state, { nowMs });
|
|
319
775
|
for (const notification of notifications) {
|
|
320
|
-
|
|
776
|
+
emitNotification(notification);
|
|
321
777
|
}
|
|
322
778
|
}
|
|
323
779
|
}
|
|
324
780
|
|
|
325
|
-
function synthesizeNotificationsFromRolloutEntry(entry, state) {
|
|
781
|
+
function synthesizeNotificationsFromRolloutEntry(entry, state, { nowMs = Date.now() } = {}) {
|
|
326
782
|
if (entry?.type === "session_meta") {
|
|
327
783
|
populateSessionMetaState(state, entry.payload);
|
|
328
784
|
if (!isDesktopRolloutOrigin(state.sessionMeta)) {
|
|
@@ -344,12 +800,15 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
|
|
|
344
800
|
const eventType = readString(payload.type);
|
|
345
801
|
|
|
346
802
|
if (eventType === "task_started") {
|
|
803
|
+
notifications.push(...finalizePendingSyntheticTerminal(state));
|
|
347
804
|
const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
|
|
348
805
|
const turnId = explicitTurnId || buildSyntheticTurnId(state, entry);
|
|
349
806
|
state.activeTurnId = turnId;
|
|
350
807
|
state.activeTurnIdIsSynthetic = !explicitTurnId;
|
|
351
808
|
state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, turnId);
|
|
352
809
|
state.hasThinking = false;
|
|
810
|
+
state.hasReasoningContent = false;
|
|
811
|
+
state.emittedReasoningSummaryKeys.clear();
|
|
353
812
|
state.commandCalls.clear();
|
|
354
813
|
state.applyPatchCalls.clear();
|
|
355
814
|
state.emittedPatchApplyEndCalls.clear();
|
|
@@ -367,44 +826,66 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
|
|
|
367
826
|
return notifications;
|
|
368
827
|
}
|
|
369
828
|
|
|
829
|
+
if (eventType && !TERMINAL_TASK_EVENT_TYPES.has(eventType)) {
|
|
830
|
+
clearPendingSyntheticTerminal(state);
|
|
831
|
+
}
|
|
832
|
+
|
|
370
833
|
if (eventType === "user_message") {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
834
|
+
// Rollouts persist injected context (AGENTS.md instructions, IDE prompt
|
|
835
|
+
// wrappers) as user_message events; only the real request is a bubble.
|
|
836
|
+
notifications.push(...userMessageNotifications(state, entry, payload));
|
|
837
|
+
return notifications;
|
|
838
|
+
}
|
|
375
839
|
|
|
376
|
-
|
|
840
|
+
if (eventType === "task_complete") {
|
|
841
|
+
const turnId = resolveRolloutEventTurnId(state, payload, { allowSyntheticPromotion: false });
|
|
377
842
|
if (!turnId) {
|
|
378
|
-
state.pendingUserMessages.push({
|
|
379
|
-
id: readString(payload.id),
|
|
380
|
-
message,
|
|
381
|
-
timestamp: readUserMessageTimestamp(entry, payload),
|
|
382
|
-
});
|
|
383
843
|
return [];
|
|
384
844
|
}
|
|
385
845
|
|
|
386
|
-
|
|
846
|
+
// Desktop runs parallel turns in one rollout: a sibling turn finishing
|
|
847
|
+
// must not wipe the tracked state of the turn that is still streaming.
|
|
848
|
+
const closesActiveRun = terminalEventClosesTrackedTurn(turnId, state.activeTurnId);
|
|
849
|
+
if (closesActiveRun) {
|
|
850
|
+
notifications.push(...turnFileChangeSnapshotNotifications(state, turnId));
|
|
851
|
+
}
|
|
852
|
+
notifications.push(createNotification("turn/completed", {
|
|
387
853
|
threadId: state.threadId,
|
|
388
854
|
turnId,
|
|
389
|
-
|
|
390
|
-
...timestampParams(readUserMessageTimestamp(entry, payload)),
|
|
855
|
+
id: turnId,
|
|
391
856
|
}));
|
|
857
|
+
if (closesActiveRun) {
|
|
858
|
+
resetRunState(state);
|
|
859
|
+
} else if (isSyntheticTerminalMismatch(state, turnId)) {
|
|
860
|
+
markPendingSyntheticTerminal(state, { status: "completed" }, nowMs);
|
|
861
|
+
}
|
|
392
862
|
return notifications;
|
|
393
863
|
}
|
|
394
864
|
|
|
395
|
-
|
|
396
|
-
|
|
865
|
+
// Aborted/failed desktop runs never write task_complete; close the mirrored
|
|
866
|
+
// turn anyway so the phone does not keep the thread pinned as running.
|
|
867
|
+
if (eventType === "turn_aborted" || eventType === "error") {
|
|
868
|
+
const turnId = resolveRolloutEventTurnId(state, payload, { allowSyntheticPromotion: false });
|
|
397
869
|
if (!turnId) {
|
|
398
870
|
return [];
|
|
399
871
|
}
|
|
400
872
|
|
|
401
|
-
|
|
402
|
-
notifications.push(createNotification("turn/completed", {
|
|
873
|
+
const terminalParams = {
|
|
403
874
|
threadId: state.threadId,
|
|
404
875
|
turnId,
|
|
405
876
|
id: turnId,
|
|
406
|
-
|
|
407
|
-
|
|
877
|
+
status: eventType === "error" ? "failed" : "aborted",
|
|
878
|
+
};
|
|
879
|
+
const errorMessage = readString(payload.message);
|
|
880
|
+
if (eventType === "error" && errorMessage) {
|
|
881
|
+
terminalParams.error = { message: errorMessage };
|
|
882
|
+
}
|
|
883
|
+
notifications.push(createNotification("turn/completed", terminalParams));
|
|
884
|
+
if (terminalEventClosesTrackedTurn(turnId, state.activeTurnId)) {
|
|
885
|
+
resetRunState(state);
|
|
886
|
+
} else if (isSyntheticTerminalMismatch(state, turnId)) {
|
|
887
|
+
markPendingSyntheticTerminal(state, terminalParams, nowMs);
|
|
888
|
+
}
|
|
408
889
|
return notifications;
|
|
409
890
|
}
|
|
410
891
|
|
|
@@ -423,18 +904,7 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
|
|
|
423
904
|
}
|
|
424
905
|
|
|
425
906
|
if (eventType === "agent_message") {
|
|
426
|
-
|
|
427
|
-
if (!message || !shouldMirrorAgentMessage(payload)) {
|
|
428
|
-
return [];
|
|
429
|
-
}
|
|
430
|
-
const turnId = resolveRolloutEventTurnId(state, payload);
|
|
431
|
-
|
|
432
|
-
notifications.push(createNotification("codex/event/agent_message", {
|
|
433
|
-
threadId: state.threadId,
|
|
434
|
-
turnId,
|
|
435
|
-
itemId: buildAgentMessageItemId(state.threadId, turnId, entry, message),
|
|
436
|
-
message,
|
|
437
|
-
}));
|
|
907
|
+
notifications.push(...agentMessageNotifications(state, entry, payload));
|
|
438
908
|
return notifications;
|
|
439
909
|
}
|
|
440
910
|
|
|
@@ -457,9 +927,16 @@ function synthesizeNotificationsFromRolloutEntry(entry, state) {
|
|
|
457
927
|
return [];
|
|
458
928
|
}
|
|
459
929
|
|
|
930
|
+
clearPendingSyntheticTerminal(state);
|
|
931
|
+
|
|
460
932
|
const payload = entry.payload || {};
|
|
461
933
|
const itemType = normalizeRolloutItemType(payload.type);
|
|
462
934
|
|
|
935
|
+
if (itemType === "message") {
|
|
936
|
+
notifications.push(...responseItemMessageNotifications(state, entry, payload));
|
|
937
|
+
return notifications;
|
|
938
|
+
}
|
|
939
|
+
|
|
463
940
|
if (itemType === "reasoning") {
|
|
464
941
|
notifications.push(...reasoningNotifications(state, extractReasoningText(payload)));
|
|
465
942
|
return notifications;
|
|
@@ -493,12 +970,32 @@ function reasoningNotifications(state, text) {
|
|
|
493
970
|
return [];
|
|
494
971
|
}
|
|
495
972
|
|
|
496
|
-
const
|
|
497
|
-
if (!
|
|
973
|
+
const rawText = readString(text);
|
|
974
|
+
if (!rawText) {
|
|
498
975
|
return ensureThinkingNotifications(state);
|
|
499
976
|
}
|
|
500
977
|
|
|
978
|
+
const summaryEntries = summaryOnlyReasoningEntries(rawText);
|
|
979
|
+
let visibleText = rawText;
|
|
980
|
+
if (summaryEntries) {
|
|
981
|
+
const unseenEntries = summaryEntries.filter((entry) => {
|
|
982
|
+
if (state.emittedReasoningSummaryKeys.has(entry.key)) {
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
state.emittedReasoningSummaryKeys.add(entry.key);
|
|
986
|
+
return true;
|
|
987
|
+
});
|
|
988
|
+
if (unseenEntries.length === 0) {
|
|
989
|
+
return [];
|
|
990
|
+
}
|
|
991
|
+
visibleText = unseenEntries
|
|
992
|
+
.map((entry) => `**${entry.title}**\n\n<!-- -->`)
|
|
993
|
+
.join("\n\n");
|
|
994
|
+
}
|
|
995
|
+
|
|
501
996
|
state.hasThinking = true;
|
|
997
|
+
const delta = `${state.hasReasoningContent ? "\n\n" : ""}${visibleText}`;
|
|
998
|
+
state.hasReasoningContent = true;
|
|
502
999
|
return [
|
|
503
1000
|
createNotification("item/reasoning/textDelta", {
|
|
504
1001
|
threadId: state.threadId,
|
|
@@ -509,6 +1006,188 @@ function reasoningNotifications(state, text) {
|
|
|
509
1006
|
];
|
|
510
1007
|
}
|
|
511
1008
|
|
|
1009
|
+
// Newer Codex rollouts write the same cumulative reasoning summaries through
|
|
1010
|
+
// both event_msg and response_item records. Recognize only title/comment-only
|
|
1011
|
+
// payloads here; detailed reasoning remains a separate opaque stream.
|
|
1012
|
+
function summaryOnlyReasoningEntries(text) {
|
|
1013
|
+
const entries = [];
|
|
1014
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1015
|
+
const line = rawLine.trim();
|
|
1016
|
+
if (!line || /^<!--.*-->$/.test(line)) {
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
const match = /^\*\*(.+?)\*\*$/.exec(line);
|
|
1020
|
+
if (!match) {
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
const title = match[1].trim();
|
|
1024
|
+
if (!title) {
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
entries.push({
|
|
1028
|
+
title,
|
|
1029
|
+
key: title.replace(/\s+/g, " ").toLowerCase(),
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return entries.length > 0 ? entries : null;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function responseItemMessageNotifications(state, entry, payload) {
|
|
1036
|
+
const role = readString(payload?.role).toLowerCase();
|
|
1037
|
+
if (role === "user") {
|
|
1038
|
+
return userMessageNotifications(state, entry, payload, {
|
|
1039
|
+
rawMessage: extractResponseItemMessageText(payload),
|
|
1040
|
+
isResponseItem: true,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
if (role && role !== "assistant") {
|
|
1044
|
+
return [];
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const message = extractResponseItemMessageText(payload);
|
|
1048
|
+
if (!message) {
|
|
1049
|
+
return [];
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return agentMessageNotifications(state, entry, {
|
|
1053
|
+
message,
|
|
1054
|
+
phase: payload?.phase,
|
|
1055
|
+
itemId: readString(payload?.id),
|
|
1056
|
+
turn_id: readString(payload?.turn_id) || readString(payload?.internal_chat_message_metadata_passthrough?.turn_id),
|
|
1057
|
+
turnId: readString(payload?.turnId) || readString(payload?.internal_chat_message_metadata_passthrough?.turnId),
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function userMessageNotifications(state, entry, payload, {
|
|
1062
|
+
rawMessage = "",
|
|
1063
|
+
isResponseItem = false,
|
|
1064
|
+
} = {}) {
|
|
1065
|
+
const imagePlaceholder = responseItemHasUserImage(payload) ? "Image attachment" : "";
|
|
1066
|
+
const message = visibleUserPromptFromInputEntries(
|
|
1067
|
+
rawMessage || readString(payload?.message) || readString(payload?.text) || imagePlaceholder
|
|
1068
|
+
);
|
|
1069
|
+
if (!message) {
|
|
1070
|
+
return [];
|
|
1071
|
+
}
|
|
1072
|
+
const turnId = resolveRolloutEventTurnId(state, payload);
|
|
1073
|
+
const itemId = readString(payload?.id) || readString(payload?.itemId) || readString(payload?.item_id);
|
|
1074
|
+
const timestamp = readUserMessageTimestamp(entry, payload);
|
|
1075
|
+
if (!turnId) {
|
|
1076
|
+
// response_item(user) can precede task_started. Hold it exactly like the
|
|
1077
|
+
// event_msg form so task_started flushes the opener before thinking/output.
|
|
1078
|
+
const pendingKey = `${itemId || ""}:${message}`;
|
|
1079
|
+
if (!state.pendingUserMessages.some((pending) => `${pending.id || ""}:${pending.message}` === pendingKey)) {
|
|
1080
|
+
state.pendingUserMessages.push({ id: itemId, message, timestamp, isResponseItem });
|
|
1081
|
+
}
|
|
1082
|
+
return [];
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const dedupeKey = userMessageOccurrenceKey(state, turnId, message, { isResponseItem });
|
|
1086
|
+
if (state.emittedUserMessageKeys.has(dedupeKey)) {
|
|
1087
|
+
return [];
|
|
1088
|
+
}
|
|
1089
|
+
state.emittedUserMessageKeys.add(dedupeKey);
|
|
1090
|
+
return [createNotification("codex/event/user_message", {
|
|
1091
|
+
threadId: state.threadId,
|
|
1092
|
+
turnId,
|
|
1093
|
+
message,
|
|
1094
|
+
...(itemId ? { id: itemId } : {}),
|
|
1095
|
+
...timestampParams(timestamp),
|
|
1096
|
+
})];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Rollouts commonly persist one user message twice as an event_msg and a
|
|
1100
|
+
// response_item pair, in either order: desktop-started turns log event_msg
|
|
1101
|
+
// first, phone/app-server-started turns log response_item first. Pair the two
|
|
1102
|
+
// shapes by occurrence in both directions instead of collapsing every
|
|
1103
|
+
// identical text in the turn, because repeated steers are legitimate.
|
|
1104
|
+
function userMessageOccurrenceKey(state, turnId, message, { isResponseItem = false } = {}) {
|
|
1105
|
+
const baseKey = buildRemodexSourceItemKey(turnId, message);
|
|
1106
|
+
const unpairedMap = isResponseItem
|
|
1107
|
+
? state.pendingEventUserMessageOccurrencesByBaseKey
|
|
1108
|
+
: state.pendingResponseItemUserMessageOccurrencesByBaseKey;
|
|
1109
|
+
const ownPendingMap = isResponseItem
|
|
1110
|
+
? state.pendingResponseItemUserMessageOccurrencesByBaseKey
|
|
1111
|
+
: state.pendingEventUserMessageOccurrencesByBaseKey;
|
|
1112
|
+
|
|
1113
|
+
const unpaired = unpairedMap.get(baseKey) || [];
|
|
1114
|
+
if (unpaired.length > 0) {
|
|
1115
|
+
const occurrence = unpaired.shift();
|
|
1116
|
+
if (unpaired.length === 0) {
|
|
1117
|
+
unpairedMap.delete(baseKey);
|
|
1118
|
+
} else {
|
|
1119
|
+
unpairedMap.set(baseKey, unpaired);
|
|
1120
|
+
}
|
|
1121
|
+
return `user:${baseKey}:${occurrence}`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const occurrence = (state.userMessageOccurrencesByBaseKey.get(baseKey) || 0) + 1;
|
|
1125
|
+
state.userMessageOccurrencesByBaseKey.set(baseKey, occurrence);
|
|
1126
|
+
const ownPending = ownPendingMap.get(baseKey) || [];
|
|
1127
|
+
ownPending.push(occurrence);
|
|
1128
|
+
ownPendingMap.set(baseKey, ownPending);
|
|
1129
|
+
return `user:${baseKey}:${occurrence}`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function responseItemHasUserImage(payload) {
|
|
1133
|
+
return Array.isArray(payload?.content) && payload.content.some((part) => {
|
|
1134
|
+
const type = readString(part?.type).toLowerCase();
|
|
1135
|
+
return type === "input_image" || type === "image" || type === "image_url";
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function agentMessageNotifications(state, entry, payload) {
|
|
1140
|
+
const message = readString(payload?.message) || readString(payload?.text);
|
|
1141
|
+
if (!message) {
|
|
1142
|
+
return [];
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const turnId = resolveRolloutEventTurnId(state, payload);
|
|
1146
|
+
const baseKey = agentMessageDedupeKey(turnId, message);
|
|
1147
|
+
const providerItemId = readString(payload?.itemId);
|
|
1148
|
+
const nextOccurrence = (state.agentMessageOccurrencesByBaseKey.get(baseKey) || 0) + 1;
|
|
1149
|
+
const occurrence = providerItemId && state.pendingEventAgentMessageOccurrencesByBaseKey.has(baseKey)
|
|
1150
|
+
? state.pendingEventAgentMessageOccurrencesByBaseKey.get(baseKey)
|
|
1151
|
+
: nextOccurrence;
|
|
1152
|
+
state.agentMessageOccurrencesByBaseKey.set(baseKey, Math.max(nextOccurrence, occurrence));
|
|
1153
|
+
if (providerItemId) {
|
|
1154
|
+
state.pendingEventAgentMessageOccurrencesByBaseKey.delete(baseKey);
|
|
1155
|
+
} else {
|
|
1156
|
+
state.pendingEventAgentMessageOccurrencesByBaseKey.set(baseKey, occurrence);
|
|
1157
|
+
}
|
|
1158
|
+
const dedupeKey = `${baseKey}:${occurrence}`;
|
|
1159
|
+
if (state.emittedAgentMessageKeys.has(dedupeKey)) {
|
|
1160
|
+
return [];
|
|
1161
|
+
}
|
|
1162
|
+
state.emittedAgentMessageKeys.add(dedupeKey);
|
|
1163
|
+
|
|
1164
|
+
// Commentary (interleaved progress prose) is mirrored too: desktop renders it
|
|
1165
|
+
// between tool calls, and dropping it would glue every tool row into one burst
|
|
1166
|
+
// on the phone. The phase rides along so the app can keep commentary rows
|
|
1167
|
+
// distinct from the final answer.
|
|
1168
|
+
const params = {
|
|
1169
|
+
threadId: state.threadId,
|
|
1170
|
+
turnId,
|
|
1171
|
+
itemId: providerItemId || buildAgentMessageItemId(state.threadId, turnId, entry, message),
|
|
1172
|
+
// The same assistant item may first arrive as event_msg (without Codex's
|
|
1173
|
+
// item id) and later as response_item/history (with one). Preserve a
|
|
1174
|
+
// stable source alias across bootstrap/reconnect so the phone can merge
|
|
1175
|
+
// those representations without using unsafe global text deduplication.
|
|
1176
|
+
...(occurrence === 1 ? { remodexSourceItemKey: baseKey } : {}),
|
|
1177
|
+
message,
|
|
1178
|
+
};
|
|
1179
|
+
const phase = readString(payload?.phase);
|
|
1180
|
+
if (phase) {
|
|
1181
|
+
params.phase = phase;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return [createNotification("codex/event/agent_message", params)];
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function extractResponseItemMessageText(payload) {
|
|
1188
|
+
return responseItemMessageText(payload);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
512
1191
|
function toolStartNotifications(state, payload) {
|
|
513
1192
|
if (!state.activeTurnId) {
|
|
514
1193
|
return [];
|
|
@@ -528,6 +1207,40 @@ function toolStartNotifications(state, payload) {
|
|
|
528
1207
|
];
|
|
529
1208
|
}
|
|
530
1209
|
|
|
1210
|
+
if (readString(toolName).toLowerCase() === "apply_patch") {
|
|
1211
|
+
const item = buildApplyPatchFileChangeItem({
|
|
1212
|
+
callId,
|
|
1213
|
+
patch: readString(argumentsObject.patch) || readString(argumentsObject.input) || readString(payload.input),
|
|
1214
|
+
status: readString(payload.status) || "completed",
|
|
1215
|
+
idFallback: buildSyntheticItemId("file-change", state.threadId, state.activeTurnId, callId),
|
|
1216
|
+
});
|
|
1217
|
+
const notifications = [...ensureThinkingNotifications(state)];
|
|
1218
|
+
if (!item) {
|
|
1219
|
+
return [
|
|
1220
|
+
...notifications,
|
|
1221
|
+
createNotification("codex/event/background_event", {
|
|
1222
|
+
threadId: state.threadId,
|
|
1223
|
+
turnId: state.activeTurnId,
|
|
1224
|
+
call_id: callId,
|
|
1225
|
+
message: genericToolActivityMessage(toolName),
|
|
1226
|
+
}),
|
|
1227
|
+
];
|
|
1228
|
+
}
|
|
1229
|
+
state.applyPatchCalls.set(callId, item);
|
|
1230
|
+
return [
|
|
1231
|
+
...notifications,
|
|
1232
|
+
createNotification("codex/event/patch_apply_begin", {
|
|
1233
|
+
threadId: state.threadId,
|
|
1234
|
+
turnId: state.activeTurnId,
|
|
1235
|
+
id: state.activeTurnId,
|
|
1236
|
+
call_id: callId,
|
|
1237
|
+
itemId: item.id,
|
|
1238
|
+
status: "inProgress",
|
|
1239
|
+
changes: item.changes,
|
|
1240
|
+
}),
|
|
1241
|
+
];
|
|
1242
|
+
}
|
|
1243
|
+
|
|
531
1244
|
state.commandCalls.set(callId, {
|
|
532
1245
|
toolName,
|
|
533
1246
|
command: resolveToolCommand(toolName, argumentsObject),
|
|
@@ -689,8 +1402,15 @@ function toolOutputNotifications(state, payload) {
|
|
|
689
1402
|
}
|
|
690
1403
|
|
|
691
1404
|
if (!isCommandToolName(toolCall.toolName)) {
|
|
1405
|
+
const notifications = [...ensureThinkingNotifications(state)];
|
|
1406
|
+
notifications.push(createNotification("codex/event/background_event", {
|
|
1407
|
+
threadId: state.threadId,
|
|
1408
|
+
turnId: state.activeTurnId,
|
|
1409
|
+
call_id: callId,
|
|
1410
|
+
message: genericToolCompletionMessage(toolCall.toolName),
|
|
1411
|
+
}));
|
|
692
1412
|
state.commandCalls.delete(callId);
|
|
693
|
-
return
|
|
1413
|
+
return notifications;
|
|
694
1414
|
}
|
|
695
1415
|
|
|
696
1416
|
const output = readString(payload.output);
|
|
@@ -789,6 +1509,75 @@ function itemCompletedNotifications(state, payload) {
|
|
|
789
1509
|
];
|
|
790
1510
|
}
|
|
791
1511
|
|
|
1512
|
+
// Synthetic turn ids are a temporary stand-in; close them if a terminal event
|
|
1513
|
+
// had no later activity proving it belonged to a sibling parallel run.
|
|
1514
|
+
function markPendingSyntheticTerminal(state, terminalParams = {}, nowMs = Date.now()) {
|
|
1515
|
+
if (state.activeTurnIdIsSynthetic && state.activeTurnId) {
|
|
1516
|
+
state.pendingSyntheticTerminalTurnId = state.activeTurnId;
|
|
1517
|
+
state.pendingSyntheticTerminalStartedAt = nowMs;
|
|
1518
|
+
state.pendingSyntheticTerminalStatus = readString(terminalParams.status) || "";
|
|
1519
|
+
state.pendingSyntheticTerminalErrorMessage = readString(terminalParams.error?.message) || "";
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function clearPendingSyntheticTerminal(state) {
|
|
1524
|
+
state.pendingSyntheticTerminalTurnId = null;
|
|
1525
|
+
state.pendingSyntheticTerminalStartedAt = 0;
|
|
1526
|
+
state.pendingSyntheticTerminalStatus = "";
|
|
1527
|
+
state.pendingSyntheticTerminalErrorMessage = "";
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function isSyntheticTerminalMismatch(state, terminalTurnId) {
|
|
1531
|
+
return Boolean(
|
|
1532
|
+
state.activeTurnIdIsSynthetic
|
|
1533
|
+
&& state.activeTurnId
|
|
1534
|
+
&& terminalTurnId
|
|
1535
|
+
&& terminalTurnId !== state.activeTurnId
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
function finalizePendingSyntheticTerminal(state) {
|
|
1540
|
+
const turnId = state.pendingSyntheticTerminalTurnId;
|
|
1541
|
+
if (!turnId) {
|
|
1542
|
+
return [];
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const terminalParams = {
|
|
1546
|
+
threadId: state.threadId,
|
|
1547
|
+
turnId,
|
|
1548
|
+
id: turnId,
|
|
1549
|
+
};
|
|
1550
|
+
if (state.pendingSyntheticTerminalStatus) {
|
|
1551
|
+
terminalParams.status = state.pendingSyntheticTerminalStatus;
|
|
1552
|
+
}
|
|
1553
|
+
if (state.pendingSyntheticTerminalErrorMessage) {
|
|
1554
|
+
terminalParams.error = { message: state.pendingSyntheticTerminalErrorMessage };
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
const notifications = [
|
|
1558
|
+
...turnFileChangeSnapshotNotifications(state, turnId),
|
|
1559
|
+
createNotification("turn/completed", terminalParams),
|
|
1560
|
+
];
|
|
1561
|
+
resetRunState(state);
|
|
1562
|
+
return notifications;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function finalizePendingSyntheticTerminalIfReady(state, nowMs, graceMs) {
|
|
1566
|
+
if (!state.pendingSyntheticTerminalTurnId) {
|
|
1567
|
+
return [];
|
|
1568
|
+
}
|
|
1569
|
+
const startedAt = Number.isFinite(state.pendingSyntheticTerminalStartedAt)
|
|
1570
|
+
? state.pendingSyntheticTerminalStartedAt
|
|
1571
|
+
: nowMs;
|
|
1572
|
+
const resolvedGraceMs = Number.isFinite(graceMs)
|
|
1573
|
+
? Math.max(0, graceMs)
|
|
1574
|
+
: DEFAULT_SYNTHETIC_TERMINAL_GRACE_MS;
|
|
1575
|
+
if (nowMs - startedAt < resolvedGraceMs) {
|
|
1576
|
+
return [];
|
|
1577
|
+
}
|
|
1578
|
+
return finalizePendingSyntheticTerminal(state);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
792
1581
|
function ensureThinkingNotifications(state) {
|
|
793
1582
|
if (!state.activeTurnId || state.hasThinking) {
|
|
794
1583
|
return [];
|
|
@@ -817,11 +1606,29 @@ function createMirrorState(threadId) {
|
|
|
817
1606
|
activeTurnId: null,
|
|
818
1607
|
reasoningItemId: null,
|
|
819
1608
|
hasThinking: false,
|
|
1609
|
+
hasReasoningContent: false,
|
|
1610
|
+
emittedReasoningSummaryKeys: new Set(),
|
|
820
1611
|
commandCalls: new Map(),
|
|
821
1612
|
applyPatchCalls: new Map(),
|
|
822
1613
|
emittedPatchApplyEndCalls: new Set(),
|
|
1614
|
+
emittedAgentMessageKeys: new Set(),
|
|
1615
|
+
agentMessageOccurrencesByBaseKey: new Map(),
|
|
1616
|
+
pendingEventAgentMessageOccurrencesByBaseKey: new Map(),
|
|
1617
|
+
emittedUserMessageKeys: new Set(),
|
|
1618
|
+
userMessageOccurrencesByBaseKey: new Map(),
|
|
1619
|
+
pendingEventUserMessageOccurrencesByBaseKey: new Map(),
|
|
1620
|
+
pendingResponseItemUserMessageOccurrencesByBaseKey: new Map(),
|
|
823
1621
|
pendingUserMessages: [],
|
|
1622
|
+
pendingSyntheticTerminalTurnId: null,
|
|
1623
|
+
pendingSyntheticTerminalStartedAt: 0,
|
|
1624
|
+
pendingSyntheticTerminalStatus: "",
|
|
1625
|
+
pendingSyntheticTerminalErrorMessage: "",
|
|
824
1626
|
activeTurnIdIsSynthetic: false,
|
|
1627
|
+
// True after a stale bootstrap: run context is hydrated but nothing is
|
|
1628
|
+
// emitted (including heartbeats) until the rollout file grows again.
|
|
1629
|
+
suppressLiveActivityUntilGrowth: false,
|
|
1630
|
+
awaitingCoherentBoundary: false,
|
|
1631
|
+
awaitingBoundaryPreludeLine: "",
|
|
825
1632
|
};
|
|
826
1633
|
}
|
|
827
1634
|
|
|
@@ -875,7 +1682,8 @@ function parseToolArguments(rawArguments) {
|
|
|
875
1682
|
|
|
876
1683
|
function planUpdateNotifications(state, argumentsObject) {
|
|
877
1684
|
const plan = normalizeProgressPlanSteps(argumentsObject.plan);
|
|
878
|
-
|
|
1685
|
+
const explanation = readString(argumentsObject.explanation);
|
|
1686
|
+
if (!hasVisiblePlanUpdate(explanation, plan)) {
|
|
879
1687
|
return [];
|
|
880
1688
|
}
|
|
881
1689
|
|
|
@@ -884,7 +1692,6 @@ function planUpdateNotifications(state, argumentsObject) {
|
|
|
884
1692
|
turnId: state.activeTurnId,
|
|
885
1693
|
plan,
|
|
886
1694
|
};
|
|
887
|
-
const explanation = readString(argumentsObject.explanation);
|
|
888
1695
|
if (explanation) {
|
|
889
1696
|
params.explanation = explanation;
|
|
890
1697
|
}
|
|
@@ -969,9 +1776,8 @@ function genericToolActivityMessage(toolName) {
|
|
|
969
1776
|
}
|
|
970
1777
|
}
|
|
971
1778
|
|
|
972
|
-
function
|
|
973
|
-
|
|
974
|
-
return phase !== "commentary";
|
|
1779
|
+
function genericToolCompletionMessage(toolName) {
|
|
1780
|
+
return `Completed ${readString(toolName)}`;
|
|
975
1781
|
}
|
|
976
1782
|
|
|
977
1783
|
function createNotification(method, params = {}) {
|
|
@@ -995,13 +1801,29 @@ function flushPendingUserMessageNotifications(state, turnId) {
|
|
|
995
1801
|
return [];
|
|
996
1802
|
}
|
|
997
1803
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1804
|
+
const resolvedTurnId = readString(turnId) || readString(state.activeTurnId);
|
|
1805
|
+
return messages
|
|
1806
|
+
.map((pending) => ({ ...pending, message: visibleUserPromptFromInputEntries(pending.message) }))
|
|
1807
|
+
.filter((pending) => pending.message)
|
|
1808
|
+
.filter((pending) => {
|
|
1809
|
+
const dedupeKey = userMessageOccurrenceKey(state, resolvedTurnId, pending.message, {
|
|
1810
|
+
isResponseItem: pending.isResponseItem === true,
|
|
1811
|
+
});
|
|
1812
|
+
if (state.emittedUserMessageKeys.has(dedupeKey)) {
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
state.emittedUserMessageKeys.add(dedupeKey);
|
|
1816
|
+
return true;
|
|
1817
|
+
})
|
|
1818
|
+
.map((pending) => createNotification("codex/event/user_message", {
|
|
1819
|
+
threadId: state.threadId,
|
|
1820
|
+
// An empty turnId reads as "no turn identity" on the phone and blocks
|
|
1821
|
+
// dedup against the turn-bound row of the same prompt; omit it instead.
|
|
1822
|
+
...(resolvedTurnId ? { turnId: resolvedTurnId } : {}),
|
|
1823
|
+
message: pending.message,
|
|
1824
|
+
...(pending.id ? { id: pending.id } : {}),
|
|
1825
|
+
...timestampParams(pending.timestamp),
|
|
1826
|
+
}));
|
|
1005
1827
|
}
|
|
1006
1828
|
|
|
1007
1829
|
function readUserMessageTimestamp(entry, payload = {}) {
|
|
@@ -1031,11 +1853,36 @@ function buildSyntheticTurnId(state, entry) {
|
|
|
1031
1853
|
return `rollout-turn:${state.threadId}:${timestamp}`;
|
|
1032
1854
|
}
|
|
1033
1855
|
|
|
1034
|
-
function resolveRolloutEventTurnId(state, payload = {}) {
|
|
1856
|
+
function resolveRolloutEventTurnId(state, payload = {}, { allowSyntheticPromotion = true } = {}) {
|
|
1857
|
+
const explicitTurnId = readString(payload.turn_id) || readString(payload.turnId);
|
|
1035
1858
|
if (state.activeTurnIdIsSynthetic && state.activeTurnId) {
|
|
1859
|
+
if (explicitTurnId) {
|
|
1860
|
+
// Terminal events must not promote: with parallel turns, a sibling's
|
|
1861
|
+
// terminal explicit id would hijack the synthetic run and wipe it. The
|
|
1862
|
+
// active run's real id is adopted from its own non-terminal events.
|
|
1863
|
+
if (allowSyntheticPromotion) {
|
|
1864
|
+
promoteSyntheticTurnId(state, explicitTurnId);
|
|
1865
|
+
}
|
|
1866
|
+
return explicitTurnId;
|
|
1867
|
+
}
|
|
1036
1868
|
return state.activeTurnId;
|
|
1037
1869
|
}
|
|
1038
|
-
return
|
|
1870
|
+
return explicitTurnId || state.activeTurnId || "";
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
function promoteSyntheticTurnId(state, explicitTurnId) {
|
|
1874
|
+
const oldTurnId = state.activeTurnId;
|
|
1875
|
+
if (!oldTurnId || oldTurnId === explicitTurnId) {
|
|
1876
|
+
state.activeTurnId = explicitTurnId;
|
|
1877
|
+
state.activeTurnIdIsSynthetic = false;
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
state.activeTurnId = explicitTurnId;
|
|
1882
|
+
state.activeTurnIdIsSynthetic = false;
|
|
1883
|
+
if (state.reasoningItemId === buildSyntheticItemId("thinking", state.threadId, oldTurnId)) {
|
|
1884
|
+
state.reasoningItemId = buildSyntheticItemId("thinking", state.threadId, explicitTurnId);
|
|
1885
|
+
}
|
|
1039
1886
|
}
|
|
1040
1887
|
|
|
1041
1888
|
function buildAgentMessageItemId(threadId, turnId, entry, message) {
|
|
@@ -1053,6 +1900,15 @@ function buildAgentMessageItemId(threadId, turnId, entry, message) {
|
|
|
1053
1900
|
);
|
|
1054
1901
|
}
|
|
1055
1902
|
|
|
1903
|
+
// Keyed on turn + text only: the same assistant text often arrives twice per
|
|
1904
|
+
// turn (event_msg agent_message and response_item message), and only one side
|
|
1905
|
+
// carries `phase`, so phase must stay out of the key for them to collide.
|
|
1906
|
+
// Legitimately repeated identical prose in one turn is rare and the phone's
|
|
1907
|
+
// item-scoped dedup covers the remainder.
|
|
1908
|
+
function agentMessageDedupeKey(turnId, message) {
|
|
1909
|
+
return buildRemodexSourceItemKey(turnId, message);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1056
1912
|
function generatedImagePathForRolloutItem(threadId, callId) {
|
|
1057
1913
|
const resolvedThreadId = readString(threadId);
|
|
1058
1914
|
const resolvedCallId = readString(callId);
|
|
@@ -1071,11 +1927,27 @@ function resetRunState(state) {
|
|
|
1071
1927
|
state.activeTurnId = null;
|
|
1072
1928
|
state.reasoningItemId = null;
|
|
1073
1929
|
state.hasThinking = false;
|
|
1930
|
+
state.hasReasoningContent = false;
|
|
1931
|
+
state.emittedReasoningSummaryKeys.clear();
|
|
1074
1932
|
state.commandCalls.clear();
|
|
1075
1933
|
state.applyPatchCalls.clear();
|
|
1076
1934
|
state.emittedPatchApplyEndCalls.clear();
|
|
1935
|
+
state.emittedAgentMessageKeys.clear();
|
|
1936
|
+
state.agentMessageOccurrencesByBaseKey.clear();
|
|
1937
|
+
state.pendingEventAgentMessageOccurrencesByBaseKey.clear();
|
|
1938
|
+
state.emittedUserMessageKeys.clear();
|
|
1939
|
+
state.userMessageOccurrencesByBaseKey.clear();
|
|
1940
|
+
state.pendingEventUserMessageOccurrencesByBaseKey.clear();
|
|
1941
|
+
state.pendingResponseItemUserMessageOccurrencesByBaseKey.clear();
|
|
1077
1942
|
state.pendingUserMessages.length = 0;
|
|
1943
|
+
state.pendingSyntheticTerminalTurnId = null;
|
|
1944
|
+
state.pendingSyntheticTerminalStartedAt = 0;
|
|
1945
|
+
state.pendingSyntheticTerminalStatus = "";
|
|
1946
|
+
state.pendingSyntheticTerminalErrorMessage = "";
|
|
1078
1947
|
state.activeTurnIdIsSynthetic = false;
|
|
1948
|
+
state.suppressLiveActivityUntilGrowth = false;
|
|
1949
|
+
state.awaitingCoherentBoundary = false;
|
|
1950
|
+
state.awaitingBoundaryPreludeLine = "";
|
|
1079
1951
|
}
|
|
1080
1952
|
|
|
1081
1953
|
function readThreadId(params) {
|
|
@@ -1085,10 +1957,6 @@ function readThreadId(params) {
|
|
|
1085
1957
|
]) || "";
|
|
1086
1958
|
}
|
|
1087
1959
|
|
|
1088
|
-
function readFileSize(filePath, fsModule) {
|
|
1089
|
-
return fsModule.statSync(filePath).size;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1960
|
function readFileSlice(filePath, start, endExclusive, fsModule) {
|
|
1093
1961
|
const length = Math.max(0, endExclusive - start);
|
|
1094
1962
|
if (length === 0) {
|