@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
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
// FILE: desktop-ipc-conversation-adapter.js
|
|
2
|
+
// Purpose: Translates app-server JSON-RPC traffic into Codex Desktop's conversationState shape.
|
|
3
|
+
// Layer: CLI helper
|
|
4
|
+
// Exports: applyAppServerMessageToConversationState, buildConversationStateFromThread, conversation turn/item helpers
|
|
5
|
+
// Depends on: crypto, ./desktop-ipc-shared
|
|
6
|
+
|
|
7
|
+
const { randomUUID } = require("crypto");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
cloneJSON,
|
|
11
|
+
hasVisiblePlanUpdate,
|
|
12
|
+
isContextualUserText,
|
|
13
|
+
isUserRoleItem: isUserMessageItem,
|
|
14
|
+
normalizeToken,
|
|
15
|
+
readString,
|
|
16
|
+
requestIdKey,
|
|
17
|
+
sanitizeUserInputEntries: sanitizeSharedUserInputEntries,
|
|
18
|
+
} = require("./desktop-ipc-shared");
|
|
19
|
+
|
|
20
|
+
const LOCAL_HOST_ID = "local";
|
|
21
|
+
|
|
22
|
+
function resolveTurnForConversation({
|
|
23
|
+
conversation,
|
|
24
|
+
turn,
|
|
25
|
+
method,
|
|
26
|
+
fallbackTurnIdsByThreadId,
|
|
27
|
+
now = () => Date.now(),
|
|
28
|
+
} = {}) {
|
|
29
|
+
const explicitTurnId = readTurnIdFromTurn(turn);
|
|
30
|
+
if (explicitTurnId) {
|
|
31
|
+
promoteFallbackTurnId(conversation, fallbackTurnIdsByThreadId, explicitTurnId);
|
|
32
|
+
return turn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const fallbackTurnId = readFallbackTurnId(conversation?.id, fallbackTurnIdsByThreadId)
|
|
36
|
+
|| (method === "turn/started" ? createSyntheticTurnId(conversation?.id, now) : "");
|
|
37
|
+
if (!fallbackTurnId) {
|
|
38
|
+
return turn;
|
|
39
|
+
}
|
|
40
|
+
fallbackTurnIdsByThreadId?.set(conversation.id, fallbackTurnId);
|
|
41
|
+
if (method === "turn/completed") {
|
|
42
|
+
fallbackTurnIdsByThreadId?.delete(conversation.id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Some app-server builds start turns before they know the canonical turn id.
|
|
46
|
+
// Keep one stable row alive until a later event can promote it to the real id.
|
|
47
|
+
return {
|
|
48
|
+
...turn,
|
|
49
|
+
id: fallbackTurnId,
|
|
50
|
+
turnId: fallbackTurnId,
|
|
51
|
+
remodexSyntheticTurnId: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveTurnIdForParams({
|
|
56
|
+
conversation,
|
|
57
|
+
params,
|
|
58
|
+
fallbackTurnIdsByThreadId,
|
|
59
|
+
allowOptimisticFallback = true,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const explicitTurnId = readTurnIdFromParams(params);
|
|
62
|
+
if (explicitTurnId) {
|
|
63
|
+
promoteFallbackTurnId(conversation, fallbackTurnIdsByThreadId, explicitTurnId);
|
|
64
|
+
return explicitTurnId;
|
|
65
|
+
}
|
|
66
|
+
const fallbackTurnId = readFallbackTurnId(conversation?.id, fallbackTurnIdsByThreadId);
|
|
67
|
+
if (fallbackTurnId) {
|
|
68
|
+
if (!allowOptimisticFallback && isOptimisticPendingTurnId(conversation, fallbackTurnId)) {
|
|
69
|
+
return latestNonOptimisticTurnId(conversation) || "";
|
|
70
|
+
}
|
|
71
|
+
return fallbackTurnId;
|
|
72
|
+
}
|
|
73
|
+
if (!allowOptimisticFallback) {
|
|
74
|
+
// Turnless file-change events belong on the latest real turn, not a pending
|
|
75
|
+
// phone-send placeholder that may temporarily be the last row.
|
|
76
|
+
return latestNonOptimisticTurnId(conversation) || "";
|
|
77
|
+
}
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function promoteFallbackTurnId(conversation, fallbackTurnIdsByThreadId, explicitTurnId) {
|
|
82
|
+
const conversationId = readString(conversation?.id);
|
|
83
|
+
const fallbackTurnId = readFallbackTurnId(conversationId, fallbackTurnIdsByThreadId);
|
|
84
|
+
if (!conversationId || !fallbackTurnId || !explicitTurnId || fallbackTurnId === explicitTurnId) {
|
|
85
|
+
fallbackTurnIdsByThreadId?.delete(conversationId);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fallbackTurn = conversation.turns.find((candidate) => (
|
|
90
|
+
readString(candidate?.turnId) || readString(candidate?.id)
|
|
91
|
+
) === fallbackTurnId);
|
|
92
|
+
if (fallbackTurn) {
|
|
93
|
+
fallbackTurn.id = explicitTurnId;
|
|
94
|
+
fallbackTurn.turnId = explicitTurnId;
|
|
95
|
+
delete fallbackTurn.remodexSyntheticTurnId;
|
|
96
|
+
}
|
|
97
|
+
fallbackTurnIdsByThreadId?.delete(conversationId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readFallbackTurnId(threadId, fallbackTurnIdsByThreadId) {
|
|
101
|
+
const normalizedThreadId = readString(threadId);
|
|
102
|
+
return normalizedThreadId && fallbackTurnIdsByThreadId instanceof Map
|
|
103
|
+
? readString(fallbackTurnIdsByThreadId.get(normalizedThreadId))
|
|
104
|
+
: "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isOptimisticPendingTurn(turn) {
|
|
108
|
+
return turn?.remodexOptimisticPendingTurn === true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isOptimisticPendingTurnId(conversation, turnId) {
|
|
112
|
+
const normalizedTurnId = readString(turnId);
|
|
113
|
+
if (!conversation || !normalizedTurnId || !Array.isArray(conversation.turns)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return conversation.turns.some((turn) => (
|
|
117
|
+
isOptimisticPendingTurn(turn)
|
|
118
|
+
&& (readString(turn?.turnId) || readString(turn?.id)) === normalizedTurnId
|
|
119
|
+
));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function latestNonOptimisticTurnId(conversation) {
|
|
123
|
+
const turns = Array.isArray(conversation?.turns) ? conversation.turns : [];
|
|
124
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
125
|
+
const turn = turns[index];
|
|
126
|
+
if (isOptimisticPendingTurn(turn)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const turnId = readString(turn?.turnId) || readString(turn?.id);
|
|
130
|
+
if (turnId) {
|
|
131
|
+
return turnId;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createSyntheticTurnId(threadId, now = () => Date.now()) {
|
|
138
|
+
const normalizedThreadId = readString(threadId) || "thread";
|
|
139
|
+
return `remodex-live-turn:${normalizedThreadId}:${now()}:${randomUUID()}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function applyAppServerMessageToConversationState({
|
|
143
|
+
conversations,
|
|
144
|
+
fallbackTurnIdsByThreadId = null,
|
|
145
|
+
pendingTurnStartParamsByThreadId = null,
|
|
146
|
+
message,
|
|
147
|
+
hostId = LOCAL_HOST_ID,
|
|
148
|
+
now = () => Date.now(),
|
|
149
|
+
shouldOwnThread = () => false,
|
|
150
|
+
} = {}) {
|
|
151
|
+
const method = readString(message?.method);
|
|
152
|
+
if (!method) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (REQUEST_METHODS_WITH_THREAD.has(method) && message.id != null) {
|
|
157
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
158
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
162
|
+
upsertRequest(conversation, {
|
|
163
|
+
id: message.id,
|
|
164
|
+
method,
|
|
165
|
+
params: cloneJSON(message.params || {}),
|
|
166
|
+
});
|
|
167
|
+
conversation.hasUnreadTurn = true;
|
|
168
|
+
conversation.updatedAt = now();
|
|
169
|
+
return { threadId, changed: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
switch (method) {
|
|
173
|
+
case "thread/started": {
|
|
174
|
+
const thread = message.params?.thread;
|
|
175
|
+
const threadId = readString(thread?.id);
|
|
176
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const previous = conversations.get(threadId) || null;
|
|
180
|
+
conversations.set(threadId, buildConversationStateFromThread(thread, {
|
|
181
|
+
previous,
|
|
182
|
+
hostId,
|
|
183
|
+
now,
|
|
184
|
+
}));
|
|
185
|
+
return { threadId, changed: true };
|
|
186
|
+
}
|
|
187
|
+
case "thread/name/updated": {
|
|
188
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
189
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
193
|
+
conversation.title = readString(message.params?.threadName)
|
|
194
|
+
|| readString(message.params?.thread_name)
|
|
195
|
+
|| readString(message.params?.name)
|
|
196
|
+
|| readString(message.params?.title)
|
|
197
|
+
|| conversation.title;
|
|
198
|
+
conversation.updatedAt = now();
|
|
199
|
+
return { threadId, changed: true };
|
|
200
|
+
}
|
|
201
|
+
case "thread/status/changed": {
|
|
202
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
203
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
207
|
+
conversation.threadRuntimeStatus = cloneJSON(message.params?.status || null);
|
|
208
|
+
conversation.updatedAt = now();
|
|
209
|
+
return { threadId, changed: true };
|
|
210
|
+
}
|
|
211
|
+
case "thread/tokenUsage/updated": {
|
|
212
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
213
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
217
|
+
conversation.latestTokenUsageInfo = cloneJSON(
|
|
218
|
+
message.params?.tokenUsage || message.params?.usage || null
|
|
219
|
+
);
|
|
220
|
+
conversation.updatedAt = now();
|
|
221
|
+
return { threadId, changed: true };
|
|
222
|
+
}
|
|
223
|
+
case "turn/started":
|
|
224
|
+
case "turn/completed": {
|
|
225
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
226
|
+
const turn = message.params?.turn;
|
|
227
|
+
if (!threadId || !shouldOwnThread(threadId) || !turn) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
231
|
+
const upsertedTurn = upsertTurn(conversation, resolveTurnForConversation({
|
|
232
|
+
conversation,
|
|
233
|
+
turn,
|
|
234
|
+
method,
|
|
235
|
+
fallbackTurnIdsByThreadId,
|
|
236
|
+
now,
|
|
237
|
+
}), { now });
|
|
238
|
+
if (method === "turn/started") {
|
|
239
|
+
applyPendingTurnStartParams(
|
|
240
|
+
conversation,
|
|
241
|
+
upsertedTurn,
|
|
242
|
+
pendingTurnStartParamsByThreadId,
|
|
243
|
+
fallbackTurnIdsByThreadId
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
conversation.updatedAt = now();
|
|
247
|
+
return { threadId, changed: true };
|
|
248
|
+
}
|
|
249
|
+
case "turn/diff/updated": {
|
|
250
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
251
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
255
|
+
const turn = ensureTurn(conversation, resolveTurnIdForParams({
|
|
256
|
+
conversation,
|
|
257
|
+
params: message.params,
|
|
258
|
+
fallbackTurnIdsByThreadId,
|
|
259
|
+
now,
|
|
260
|
+
}), { now });
|
|
261
|
+
if (turn) {
|
|
262
|
+
turn.diff = readString(message.params?.diff) || "";
|
|
263
|
+
}
|
|
264
|
+
conversation.updatedAt = now();
|
|
265
|
+
return { threadId, changed: true };
|
|
266
|
+
}
|
|
267
|
+
case "turn/plan/updated": {
|
|
268
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
269
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const explanation = readString(message.params?.explanation);
|
|
273
|
+
const plan = Array.isArray(message.params?.plan) ? cloneJSON(message.params.plan) : [];
|
|
274
|
+
if (!hasVisiblePlanUpdate(explanation, plan)) {
|
|
275
|
+
return { threadId, changed: false };
|
|
276
|
+
}
|
|
277
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
278
|
+
const turn = ensureTurn(conversation, resolveTurnIdForParams({
|
|
279
|
+
conversation,
|
|
280
|
+
params: message.params,
|
|
281
|
+
fallbackTurnIdsByThreadId,
|
|
282
|
+
now,
|
|
283
|
+
}), { now });
|
|
284
|
+
if (turn) {
|
|
285
|
+
upsertItem(turn, {
|
|
286
|
+
id: `todo-list-${message.params?.turnId || now()}`,
|
|
287
|
+
type: "todo-list",
|
|
288
|
+
explanation: explanation || null,
|
|
289
|
+
plan,
|
|
290
|
+
remodexProgressPlan: true,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
conversation.updatedAt = now();
|
|
294
|
+
return { threadId, changed: true };
|
|
295
|
+
}
|
|
296
|
+
case "item/started":
|
|
297
|
+
case "item/completed": {
|
|
298
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
299
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
303
|
+
const allowOptimisticFallback = !isFileChangeLikeItemType(message.params?.item?.type);
|
|
304
|
+
const turn = ensureTurn(conversation, resolveTurnIdForParams({
|
|
305
|
+
conversation,
|
|
306
|
+
params: message.params,
|
|
307
|
+
fallbackTurnIdsByThreadId,
|
|
308
|
+
allowOptimisticFallback,
|
|
309
|
+
now,
|
|
310
|
+
}), { now, allowLastTurnFallback: allowOptimisticFallback });
|
|
311
|
+
if (turn && message.params?.item) {
|
|
312
|
+
upsertItem(turn, cloneJSON(message.params.item));
|
|
313
|
+
// Desktop derives its "Worked for Ns" divider from these two marks, not
|
|
314
|
+
// from durationMs, so keep them set on every lifecycle path.
|
|
315
|
+
if (message.params.item.type === "agentMessage") {
|
|
316
|
+
turn.finalAssistantStartedAtMs = turn.finalAssistantStartedAtMs || now();
|
|
317
|
+
}
|
|
318
|
+
if (message.params.item.type && message.params.item.type !== "userMessage") {
|
|
319
|
+
turn.firstTurnWorkItemStartedAtMs = turn.firstTurnWorkItemStartedAtMs || now();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
conversation.updatedAt = now();
|
|
323
|
+
return { threadId, changed: true };
|
|
324
|
+
}
|
|
325
|
+
case "item/agentMessage/delta":
|
|
326
|
+
case "item/plan/delta":
|
|
327
|
+
case "item/reasoning/summaryTextDelta":
|
|
328
|
+
case "item/reasoning/textDelta":
|
|
329
|
+
case "item/fileChange/outputDelta":
|
|
330
|
+
case "item/commandExecution/outputDelta":
|
|
331
|
+
case "command/exec/outputDelta": {
|
|
332
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
333
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
337
|
+
const allowOptimisticFallback = method !== "item/fileChange/outputDelta";
|
|
338
|
+
applyDeltaNotification(conversation, method, message.params || {}, {
|
|
339
|
+
fallbackTurnIdsByThreadId,
|
|
340
|
+
allowOptimisticFallback,
|
|
341
|
+
now,
|
|
342
|
+
});
|
|
343
|
+
conversation.updatedAt = now();
|
|
344
|
+
return { threadId, changed: true };
|
|
345
|
+
}
|
|
346
|
+
case "serverRequest/resolved": {
|
|
347
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
348
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
352
|
+
const requestId = requestIdKey(message.params?.requestId || message.params?.request_id);
|
|
353
|
+
conversation.requests = conversation.requests.filter((request) => requestIdKey(request.id) !== requestId);
|
|
354
|
+
conversation.updatedAt = now();
|
|
355
|
+
return { threadId, changed: true };
|
|
356
|
+
}
|
|
357
|
+
case "error": {
|
|
358
|
+
const threadId = readThreadIdFromParams(message.params);
|
|
359
|
+
if (!threadId || !shouldOwnThread(threadId)) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const conversation = ensureConversationInMap(conversations, threadId, { hostId, now });
|
|
363
|
+
const turn = ensureTurn(conversation, resolveTurnIdForParams({
|
|
364
|
+
conversation,
|
|
365
|
+
params: message.params,
|
|
366
|
+
fallbackTurnIdsByThreadId,
|
|
367
|
+
now,
|
|
368
|
+
}), { now });
|
|
369
|
+
if (turn) {
|
|
370
|
+
turn.items.push({
|
|
371
|
+
id: `error-${now()}`,
|
|
372
|
+
type: "error",
|
|
373
|
+
message: readString(message.params?.error?.message) || "Codex error",
|
|
374
|
+
willRetry: Boolean(message.params?.willRetry),
|
|
375
|
+
errorInfo: message.params?.error?.codexErrorInfo || null,
|
|
376
|
+
additionalDetails: message.params?.error?.additionalDetails || null,
|
|
377
|
+
});
|
|
378
|
+
turn.error = cloneJSON(message.params?.error || null);
|
|
379
|
+
}
|
|
380
|
+
conversation.updatedAt = now();
|
|
381
|
+
return { threadId, changed: true };
|
|
382
|
+
}
|
|
383
|
+
default:
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function buildConversationStateFromThread(thread, {
|
|
389
|
+
previous = null,
|
|
390
|
+
hostId = LOCAL_HOST_ID,
|
|
391
|
+
now = () => Date.now(),
|
|
392
|
+
} = {}) {
|
|
393
|
+
const threadId = readString(thread?.id);
|
|
394
|
+
const createdAtMs = timestampSecondsToMs(thread?.createdAt) || previous?.createdAt || now();
|
|
395
|
+
const updatedAtMs = timestampSecondsToMs(thread?.updatedAt) || now();
|
|
396
|
+
const cwd = readString(thread?.cwd) || previous?.cwd || "";
|
|
397
|
+
const latestModel = readString(thread?.model) || readString(thread?.modelProvider) || previous?.latestModel || "";
|
|
398
|
+
const turns = mergeConversationTurnsFromThread(thread?.turns, {
|
|
399
|
+
previousTurns: previous?.turns,
|
|
400
|
+
threadId,
|
|
401
|
+
cwd,
|
|
402
|
+
now,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
id: threadId,
|
|
407
|
+
hostId,
|
|
408
|
+
turns,
|
|
409
|
+
requests: cloneJSON(previous?.requests || []),
|
|
410
|
+
createdAt: createdAtMs,
|
|
411
|
+
updatedAt: updatedAtMs,
|
|
412
|
+
title: readString(thread?.name) || previous?.title || null,
|
|
413
|
+
latestModel,
|
|
414
|
+
latestReasoningEffort: previous?.latestReasoningEffort || null,
|
|
415
|
+
latestServiceTier: previous?.latestServiceTier || null,
|
|
416
|
+
previousTurnModel: previous?.previousTurnModel || null,
|
|
417
|
+
latestCollaborationMode: previous?.latestCollaborationMode || {
|
|
418
|
+
mode: "default",
|
|
419
|
+
settings: {
|
|
420
|
+
reasoning_effort: null,
|
|
421
|
+
model: latestModel,
|
|
422
|
+
developer_instructions: null,
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
hasUnreadTurn: Boolean(previous?.hasUnreadTurn),
|
|
426
|
+
unreadMessageCount: Number.isFinite(previous?.unreadMessageCount) ? previous.unreadMessageCount : 0,
|
|
427
|
+
threadGoal: previous?.threadGoal || null,
|
|
428
|
+
completedThreadGoal: previous?.completedThreadGoal || null,
|
|
429
|
+
threadRuntimeStatus: cloneJSON(thread?.status || previous?.threadRuntimeStatus || null),
|
|
430
|
+
rolloutPath: readString(thread?.path) || previous?.rolloutPath || "",
|
|
431
|
+
cwd,
|
|
432
|
+
gitInfo: cloneJSON(thread?.gitInfo || previous?.gitInfo || null),
|
|
433
|
+
resumeState: "resumed",
|
|
434
|
+
latestTokenUsageInfo: cloneJSON(previous?.latestTokenUsageInfo || null),
|
|
435
|
+
workspaceKind: previous?.workspaceKind || "project",
|
|
436
|
+
workspaceBrowserRoot: previous?.workspaceBrowserRoot || null,
|
|
437
|
+
projectlessOutputDirectory: previous?.projectlessOutputDirectory || null,
|
|
438
|
+
currentPermissions: cloneJSON(previous?.currentPermissions || null),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function mergeConversationTurnsFromThread(threadTurns, {
|
|
443
|
+
previousTurns = [],
|
|
444
|
+
threadId = "",
|
|
445
|
+
cwd = "",
|
|
446
|
+
now = () => Date.now(),
|
|
447
|
+
} = {}) {
|
|
448
|
+
const previousList = Array.isArray(previousTurns) ? previousTurns : [];
|
|
449
|
+
if (!Array.isArray(threadTurns) || threadTurns.length === 0) {
|
|
450
|
+
return cloneJSON(previousList);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const mergedById = new Map();
|
|
454
|
+
previousList.forEach((turn, index) => {
|
|
455
|
+
const turnId = readString(turn?.turnId) || readString(turn?.id);
|
|
456
|
+
if (!turnId) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
mergedById.set(turnId, {
|
|
460
|
+
turn: cloneJSON(turn),
|
|
461
|
+
order: index,
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
threadTurns.forEach((turn, index) => {
|
|
466
|
+
const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
|
|
467
|
+
if (!turnId) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const previous = mergedById.get(turnId);
|
|
471
|
+
const previousTurn = previous?.turn || null;
|
|
472
|
+
mergedById.set(turnId, {
|
|
473
|
+
turn: buildConversationTurn(turn, {
|
|
474
|
+
threadId,
|
|
475
|
+
cwd,
|
|
476
|
+
previousTurn,
|
|
477
|
+
now,
|
|
478
|
+
}),
|
|
479
|
+
order: previous?.order ?? previousList.length + index,
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return Array.from(mergedById.values())
|
|
484
|
+
.sort((left, right) => {
|
|
485
|
+
const leftStartedAt = Number(left.turn?.turnStartedAtMs);
|
|
486
|
+
const rightStartedAt = Number(right.turn?.turnStartedAtMs);
|
|
487
|
+
if (Number.isFinite(leftStartedAt)
|
|
488
|
+
&& Number.isFinite(rightStartedAt)
|
|
489
|
+
&& leftStartedAt !== rightStartedAt) {
|
|
490
|
+
return leftStartedAt - rightStartedAt;
|
|
491
|
+
}
|
|
492
|
+
return left.order - right.order;
|
|
493
|
+
})
|
|
494
|
+
.map((entry) => entry.turn);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function createEmptyConversationState(threadId, {
|
|
498
|
+
hostId = LOCAL_HOST_ID,
|
|
499
|
+
now = () => Date.now(),
|
|
500
|
+
cwd = "",
|
|
501
|
+
} = {}) {
|
|
502
|
+
const timestamp = now();
|
|
503
|
+
return {
|
|
504
|
+
id: threadId,
|
|
505
|
+
hostId,
|
|
506
|
+
turns: [],
|
|
507
|
+
requests: [],
|
|
508
|
+
createdAt: timestamp,
|
|
509
|
+
updatedAt: timestamp,
|
|
510
|
+
title: null,
|
|
511
|
+
latestModel: "",
|
|
512
|
+
latestReasoningEffort: null,
|
|
513
|
+
latestServiceTier: null,
|
|
514
|
+
previousTurnModel: null,
|
|
515
|
+
latestCollaborationMode: {
|
|
516
|
+
mode: "default",
|
|
517
|
+
settings: {
|
|
518
|
+
reasoning_effort: null,
|
|
519
|
+
model: "",
|
|
520
|
+
developer_instructions: null,
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
hasUnreadTurn: false,
|
|
524
|
+
unreadMessageCount: 0,
|
|
525
|
+
threadGoal: null,
|
|
526
|
+
completedThreadGoal: null,
|
|
527
|
+
threadRuntimeStatus: null,
|
|
528
|
+
rolloutPath: "",
|
|
529
|
+
cwd: readString(cwd) || "",
|
|
530
|
+
gitInfo: null,
|
|
531
|
+
resumeState: "resumed",
|
|
532
|
+
latestTokenUsageInfo: null,
|
|
533
|
+
workspaceKind: "project",
|
|
534
|
+
workspaceBrowserRoot: null,
|
|
535
|
+
projectlessOutputDirectory: null,
|
|
536
|
+
currentPermissions: null,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function buildConversationTurn(turn, {
|
|
541
|
+
threadId = "",
|
|
542
|
+
cwd = "",
|
|
543
|
+
previousTurn = null,
|
|
544
|
+
now = () => Date.now(),
|
|
545
|
+
} = {}) {
|
|
546
|
+
const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
|
|
547
|
+
const params = cloneJSON(previousTurn?.params || {
|
|
548
|
+
threadId,
|
|
549
|
+
input: [],
|
|
550
|
+
cwd: cwd || null,
|
|
551
|
+
approvalPolicy: null,
|
|
552
|
+
approvalsReviewer: null,
|
|
553
|
+
sandboxPolicy: null,
|
|
554
|
+
model: null,
|
|
555
|
+
serviceTier: null,
|
|
556
|
+
effort: null,
|
|
557
|
+
summary: "none",
|
|
558
|
+
personality: null,
|
|
559
|
+
outputSchema: null,
|
|
560
|
+
collaborationMode: null,
|
|
561
|
+
attachments: [],
|
|
562
|
+
});
|
|
563
|
+
const builtTurn = {
|
|
564
|
+
id: turnId,
|
|
565
|
+
turnId,
|
|
566
|
+
params,
|
|
567
|
+
turnStartedAtMs: timestampSecondsToMs(turn?.startedAt) || previousTurn?.turnStartedAtMs || now(),
|
|
568
|
+
durationMs: turn?.durationMs ?? previousTurn?.durationMs ?? null,
|
|
569
|
+
firstTurnWorkItemStartedAtMs: previousTurn?.firstTurnWorkItemStartedAtMs || null,
|
|
570
|
+
finalAssistantStartedAtMs: previousTurn?.finalAssistantStartedAtMs || null,
|
|
571
|
+
status: turn?.status || previousTurn?.status || "inProgress",
|
|
572
|
+
error: cloneJSON(turn?.error || previousTurn?.error || null),
|
|
573
|
+
diff: previousTurn?.diff || null,
|
|
574
|
+
hookRuns: cloneJSON(previousTurn?.hookRuns || []),
|
|
575
|
+
commandExecutionStartedAtMsById: cloneJSON(previousTurn?.commandExecutionStartedAtMsById || {}),
|
|
576
|
+
items: Array.isArray(turn?.items) && turn.items.length > 0
|
|
577
|
+
? cloneJSON(turn.items)
|
|
578
|
+
: cloneJSON(previousTurn?.items || []),
|
|
579
|
+
};
|
|
580
|
+
// Injected context (AGENTS.md instructions, environment_context) rides inside
|
|
581
|
+
// turn.items on turn/started, turn/completed, and hydrated thread/read turns.
|
|
582
|
+
// Drop it here, position-independently, so no Desktop snapshot path leaks it
|
|
583
|
+
// as a user bubble regardless of where the app-server placed it in the turn.
|
|
584
|
+
builtTurn.items = builtTurn.items
|
|
585
|
+
.map(sanitizeUserMessageItem)
|
|
586
|
+
.filter(Boolean);
|
|
587
|
+
// Hydrated turns from thread/read carry the prompt as an item with empty
|
|
588
|
+
// params.input; adopt it into params so Desktop renders a normal bubble.
|
|
589
|
+
normalizeTurnInitialPrompt(builtTurn);
|
|
590
|
+
return builtTurn;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function applyPendingTurnStartParams(
|
|
594
|
+
conversation,
|
|
595
|
+
turn,
|
|
596
|
+
pendingTurnStartParamsByThreadId,
|
|
597
|
+
fallbackTurnIdsByThreadId = null
|
|
598
|
+
) {
|
|
599
|
+
if (!turn || !(pendingTurnStartParamsByThreadId instanceof Map)) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const threadId = readString(conversation?.id);
|
|
603
|
+
const queue = threadId ? pendingTurnStartParamsByThreadId.get(threadId) : null;
|
|
604
|
+
const pendingEntry = Array.isArray(queue) ? queue.shift() : null;
|
|
605
|
+
if (Array.isArray(queue) && queue.length === 0) {
|
|
606
|
+
pendingTurnStartParamsByThreadId.delete(threadId);
|
|
607
|
+
}
|
|
608
|
+
if (pendingEntry) {
|
|
609
|
+
pendingEntry.consumed = true;
|
|
610
|
+
}
|
|
611
|
+
releaseConsumedOptimisticFallback(threadId, pendingEntry, fallbackTurnIdsByThreadId);
|
|
612
|
+
const pendingParams = pendingEntry?.params;
|
|
613
|
+
if (!pendingParams) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
applyTurnRuntimeMetadata(conversation, pendingParams);
|
|
618
|
+
|
|
619
|
+
const input = Array.isArray(pendingParams.input) ? pendingParams.input : [];
|
|
620
|
+
if (input.length === 0) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
turn.params = {
|
|
625
|
+
...turn.params,
|
|
626
|
+
...cloneJSON(pendingParams),
|
|
627
|
+
};
|
|
628
|
+
normalizeTurnInitialPrompt(turn);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function releaseConsumedOptimisticFallback(threadId, pendingEntry, fallbackTurnIdsByThreadId) {
|
|
632
|
+
const consumedOptimisticTurnId = readString(pendingEntry?.optimisticTurnId);
|
|
633
|
+
if (!threadId || !consumedOptimisticTurnId || !(fallbackTurnIdsByThreadId instanceof Map)) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (readString(fallbackTurnIdsByThreadId.get(threadId)) === consumedOptimisticTurnId) {
|
|
637
|
+
fallbackTurnIdsByThreadId.delete(threadId);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function applyTurnRuntimeMetadata(conversation, turnParams) {
|
|
642
|
+
if (!conversation || !turnParams) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const model = readString(turnParams.model);
|
|
646
|
+
const effort = readString(turnParams.effort);
|
|
647
|
+
const serviceTier = readString(turnParams.serviceTier) || null;
|
|
648
|
+
if (model) {
|
|
649
|
+
conversation.previousTurnModel = conversation.latestModel || null;
|
|
650
|
+
conversation.latestModel = model;
|
|
651
|
+
}
|
|
652
|
+
if (effort) {
|
|
653
|
+
conversation.latestReasoningEffort = effort;
|
|
654
|
+
}
|
|
655
|
+
conversation.latestServiceTier = serviceTier;
|
|
656
|
+
if (turnParams.collaborationMode && typeof turnParams.collaborationMode === "object") {
|
|
657
|
+
conversation.latestCollaborationMode = cloneJSON(turnParams.collaborationMode);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (!model && !effort) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const settings = conversation.latestCollaborationMode?.settings;
|
|
664
|
+
conversation.latestCollaborationMode = {
|
|
665
|
+
mode: conversation.latestCollaborationMode?.mode || "default",
|
|
666
|
+
settings: {
|
|
667
|
+
...(settings && typeof settings === "object" ? settings : {
|
|
668
|
+
developer_instructions: null,
|
|
669
|
+
}),
|
|
670
|
+
model: model || settings?.model || "",
|
|
671
|
+
reasoning_effort: effort || settings?.reasoning_effort || null,
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function turnHasUserMessageItem(turn) {
|
|
677
|
+
return turn.items.some((item) => isUserMessageItem(item));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const PRE_PROMPT_META_ITEM_TYPES = new Set([
|
|
681
|
+
"automaticapprovalreview",
|
|
682
|
+
"forkedfromconversation",
|
|
683
|
+
"modelchanged",
|
|
684
|
+
"modelrerouted",
|
|
685
|
+
"personalitychanged",
|
|
686
|
+
"remotetaskcreated",
|
|
687
|
+
"worktreeinit",
|
|
688
|
+
]);
|
|
689
|
+
|
|
690
|
+
function extractUserText(entries) {
|
|
691
|
+
if (typeof entries === "string") {
|
|
692
|
+
return entries.trim();
|
|
693
|
+
}
|
|
694
|
+
if (!Array.isArray(entries)) {
|
|
695
|
+
return "";
|
|
696
|
+
}
|
|
697
|
+
return entries
|
|
698
|
+
.map((entry) => {
|
|
699
|
+
if (typeof entry === "string") {
|
|
700
|
+
return entry;
|
|
701
|
+
}
|
|
702
|
+
if (!entry || typeof entry !== "object") {
|
|
703
|
+
return "";
|
|
704
|
+
}
|
|
705
|
+
return typeof entry.text === "string" ? entry.text : "";
|
|
706
|
+
})
|
|
707
|
+
.filter(Boolean)
|
|
708
|
+
.join("\n")
|
|
709
|
+
.trim();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function sanitizeUserInputEntries(entries) {
|
|
713
|
+
return sanitizeSharedUserInputEntries(entries).map(cloneJSON);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function sanitizeUserMessageItem(item) {
|
|
717
|
+
if (!isUserMessageItem(item)) {
|
|
718
|
+
return item;
|
|
719
|
+
}
|
|
720
|
+
const rawText = extractUserText(item?.content);
|
|
721
|
+
const sanitizedContent = sanitizeUserInputEntries(Array.isArray(item?.content) ? item.content : []);
|
|
722
|
+
if (rawText && sanitizedContent.length === 0) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
if (!Array.isArray(item?.content)) {
|
|
726
|
+
return cloneJSON(item);
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
...cloneJSON(item),
|
|
730
|
+
content: sanitizedContent,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function isInitialPromptUserMessageItem(turn, item) {
|
|
735
|
+
if (!isUserMessageItem(item)) {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
const promptText = extractUserText(turn?.params?.input);
|
|
739
|
+
if (!promptText) {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
return extractUserText(item?.content) === promptText;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function isContextualUserMessageItem(item) {
|
|
746
|
+
if (!isUserMessageItem(item)) {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
const text = extractUserText(item?.content);
|
|
750
|
+
return Boolean(text) && isContextualUserText(text);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function normalizeTurnInitialPrompt(turn) {
|
|
754
|
+
if (!turn || !Array.isArray(turn.items)) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (Array.isArray(turn.params?.input)) {
|
|
758
|
+
turn.params = {
|
|
759
|
+
...turn.params,
|
|
760
|
+
input: sanitizeUserInputEntries(turn.params.input),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const promptText = extractUserText(turn?.params?.input);
|
|
764
|
+
for (let index = 0; index < turn.items.length; index += 1) {
|
|
765
|
+
let item = turn.items[index];
|
|
766
|
+
const sanitizedItem = sanitizeUserMessageItem(item);
|
|
767
|
+
if (!sanitizedItem) {
|
|
768
|
+
turn.items.splice(index, 1);
|
|
769
|
+
index -= 1;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (sanitizedItem !== item) {
|
|
773
|
+
turn.items[index] = sanitizedItem;
|
|
774
|
+
item = sanitizedItem;
|
|
775
|
+
}
|
|
776
|
+
// Injected context user items sit before the real prompt in persisted
|
|
777
|
+
// history; strip them so they are never adopted as the prompt bubble.
|
|
778
|
+
if (isContextualUserMessageItem(item)) {
|
|
779
|
+
turn.items.splice(index, 1);
|
|
780
|
+
index -= 1;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (isUserMessageItem(item)) {
|
|
784
|
+
const itemText = extractUserText(item?.content);
|
|
785
|
+
if (!itemText) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!promptText) {
|
|
789
|
+
if (adoptInitialPromptUserMessage(turn, item)) {
|
|
790
|
+
turn.items.splice(index, 1);
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (itemText === promptText) {
|
|
795
|
+
turn.items.splice(index, 1);
|
|
796
|
+
}
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (!PRE_PROMPT_META_ITEM_TYPES.has(normalizeToken(item?.type))) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function userMessageContentFromTurnInput(entry) {
|
|
806
|
+
if (typeof entry === "string") {
|
|
807
|
+
const text = readString(entry);
|
|
808
|
+
return text ? { type: "text", text } : null;
|
|
809
|
+
}
|
|
810
|
+
if (!entry || typeof entry !== "object") {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
const type = normalizeToken(entry.type);
|
|
814
|
+
if (type === "inputtext" || type === "text") {
|
|
815
|
+
return { type: "text", text: readString(entry.text) };
|
|
816
|
+
}
|
|
817
|
+
return cloneJSON(entry);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function adoptInitialPromptUserMessage(turn, item) {
|
|
821
|
+
if (!isUserMessageItem(item) || !Array.isArray(item?.content)) {
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
const input = item.content
|
|
825
|
+
.map(userMessageContentFromTurnInput)
|
|
826
|
+
.filter(Boolean);
|
|
827
|
+
if (input.length === 0) {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
turn.params = {
|
|
831
|
+
...turn.params,
|
|
832
|
+
input,
|
|
833
|
+
};
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function ensureConversationInMap(conversations, threadId, options = {}) {
|
|
838
|
+
let conversation = conversations.get(threadId);
|
|
839
|
+
if (!conversation) {
|
|
840
|
+
conversation = createEmptyConversationState(threadId, options);
|
|
841
|
+
conversations.set(threadId, conversation);
|
|
842
|
+
}
|
|
843
|
+
return conversation;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function upsertTurn(conversation, turn, { now = () => Date.now() } = {}) {
|
|
847
|
+
const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
|
|
848
|
+
if (!turnId) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
const index = conversation.turns.findIndex((candidate) => (
|
|
852
|
+
readString(candidate?.turnId) || readString(candidate?.id)
|
|
853
|
+
) === turnId);
|
|
854
|
+
const previousTurn = index >= 0 ? conversation.turns[index] : null;
|
|
855
|
+
const nextTurn = buildConversationTurn(turn, {
|
|
856
|
+
threadId: conversation.id,
|
|
857
|
+
cwd: conversation.cwd,
|
|
858
|
+
previousTurn,
|
|
859
|
+
now,
|
|
860
|
+
});
|
|
861
|
+
if (index >= 0) {
|
|
862
|
+
conversation.turns[index] = nextTurn;
|
|
863
|
+
} else {
|
|
864
|
+
conversation.turns.push(nextTurn);
|
|
865
|
+
}
|
|
866
|
+
return nextTurn;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function ensureTurn(conversation, turnId, {
|
|
870
|
+
now = () => Date.now(),
|
|
871
|
+
allowLastTurnFallback = true,
|
|
872
|
+
} = {}) {
|
|
873
|
+
const normalizedTurnId = readString(turnId);
|
|
874
|
+
if (!normalizedTurnId) {
|
|
875
|
+
if (!allowLastTurnFallback) {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
return conversation.turns[conversation.turns.length - 1] || null;
|
|
879
|
+
}
|
|
880
|
+
let turn = conversation.turns.find((candidate) => (
|
|
881
|
+
readString(candidate?.turnId) || readString(candidate?.id)
|
|
882
|
+
) === normalizedTurnId);
|
|
883
|
+
if (!turn) {
|
|
884
|
+
turn = buildConversationTurn({
|
|
885
|
+
id: normalizedTurnId,
|
|
886
|
+
status: "inProgress",
|
|
887
|
+
items: [],
|
|
888
|
+
startedAt: null,
|
|
889
|
+
completedAt: null,
|
|
890
|
+
durationMs: null,
|
|
891
|
+
error: null,
|
|
892
|
+
}, {
|
|
893
|
+
threadId: conversation.id,
|
|
894
|
+
cwd: conversation.cwd,
|
|
895
|
+
now,
|
|
896
|
+
});
|
|
897
|
+
conversation.turns.push(turn);
|
|
898
|
+
}
|
|
899
|
+
return turn;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function upsertItem(turn, item) {
|
|
903
|
+
const itemId = readString(item?.id);
|
|
904
|
+
if (!itemId) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
// Injected context (AGENTS.md instructions, environment_context) arrives as
|
|
908
|
+
// user items too; no Codex UI renders it, so it must not reach the stream.
|
|
909
|
+
// Also evict any copy that slipped into the state before this filter existed.
|
|
910
|
+
const index = turn.items.findIndex((candidate) => readString(candidate?.id) === itemId);
|
|
911
|
+
const sanitizedItem = sanitizeUserMessageItem(item);
|
|
912
|
+
const existingItem = index >= 0 ? sanitizeUserMessageItem(turn.items[index]) : null;
|
|
913
|
+
if (!sanitizedItem) {
|
|
914
|
+
if (index >= 0) {
|
|
915
|
+
turn.items.splice(index, 1);
|
|
916
|
+
}
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (index >= 0) {
|
|
920
|
+
turn.items[index] = {
|
|
921
|
+
...(existingItem || {}),
|
|
922
|
+
...cloneJSON(sanitizedItem),
|
|
923
|
+
};
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
// The app-server echoes the initial prompt as a userMessage item; Desktop
|
|
927
|
+
// already renders it from turn.params.input and would label the duplicate as
|
|
928
|
+
// "Steered conversation". Only later user messages are genuine steers.
|
|
929
|
+
if (isUserMessageItem(sanitizedItem) && !turnHasUserMessageItem(turn)) {
|
|
930
|
+
if (isInitialPromptUserMessageItem(turn, sanitizedItem)) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (!extractUserText(turn?.params?.input) && adoptInitialPromptUserMessage(turn, sanitizedItem)) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
turn.items.push(cloneJSON(sanitizedItem));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function upsertRequest(conversation, request) {
|
|
941
|
+
const requestId = requestIdKey(request?.id);
|
|
942
|
+
if (!requestId) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const index = conversation.requests.findIndex((candidate) => requestIdKey(candidate?.id) === requestId);
|
|
946
|
+
const nextRequest = cloneJSON({
|
|
947
|
+
id: request.id,
|
|
948
|
+
method: request.method,
|
|
949
|
+
params: request.params || {},
|
|
950
|
+
});
|
|
951
|
+
if (index >= 0) {
|
|
952
|
+
conversation.requests[index] = nextRequest;
|
|
953
|
+
} else {
|
|
954
|
+
conversation.requests.push(nextRequest);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function applyDeltaNotification(conversation, method, params, {
|
|
959
|
+
fallbackTurnIdsByThreadId = null,
|
|
960
|
+
allowOptimisticFallback = true,
|
|
961
|
+
now = () => Date.now(),
|
|
962
|
+
} = {}) {
|
|
963
|
+
const turn = ensureTurn(conversation, resolveTurnIdForParams({
|
|
964
|
+
conversation,
|
|
965
|
+
params,
|
|
966
|
+
fallbackTurnIdsByThreadId,
|
|
967
|
+
allowOptimisticFallback,
|
|
968
|
+
now,
|
|
969
|
+
}), { now, allowLastTurnFallback: allowOptimisticFallback });
|
|
970
|
+
if (!turn) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const itemId = readString(params.itemId) || readString(params.item_id);
|
|
974
|
+
if (!itemId) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const delta = typeof params.delta === "string" ? params.delta : "";
|
|
978
|
+
if (!delta) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Deltas prove work is in progress even when item/started was missed (e.g.
|
|
983
|
+
// the bridge joined mid-turn); Desktop's worked-for divider needs the mark.
|
|
984
|
+
turn.firstTurnWorkItemStartedAtMs = turn.firstTurnWorkItemStartedAtMs || now();
|
|
985
|
+
|
|
986
|
+
if (method === "item/agentMessage/delta") {
|
|
987
|
+
const item = ensureItemOfType(turn, itemId, () => ({
|
|
988
|
+
type: "agentMessage",
|
|
989
|
+
id: itemId,
|
|
990
|
+
text: "",
|
|
991
|
+
phase: null,
|
|
992
|
+
memoryCitation: null,
|
|
993
|
+
}));
|
|
994
|
+
item.text = `${item.text || ""}${delta}`;
|
|
995
|
+
turn.finalAssistantStartedAtMs = turn.finalAssistantStartedAtMs || now();
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (method === "item/plan/delta") {
|
|
1000
|
+
const item = ensureItemOfType(turn, itemId, () => ({
|
|
1001
|
+
type: "plan",
|
|
1002
|
+
id: itemId,
|
|
1003
|
+
text: "",
|
|
1004
|
+
}));
|
|
1005
|
+
item.text = `${item.text || ""}${delta}`;
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") {
|
|
1010
|
+
const item = ensureItemOfType(turn, itemId, () => ({
|
|
1011
|
+
type: "reasoning",
|
|
1012
|
+
id: itemId,
|
|
1013
|
+
summary: [],
|
|
1014
|
+
content: [],
|
|
1015
|
+
}));
|
|
1016
|
+
if (method === "item/reasoning/summaryTextDelta") {
|
|
1017
|
+
const index = Number.isInteger(params.summaryIndex) ? params.summaryIndex : 0;
|
|
1018
|
+
item.summary = growArray(item.summary, index, "");
|
|
1019
|
+
item.summary[index] = `${item.summary[index] || ""}${delta}`;
|
|
1020
|
+
} else {
|
|
1021
|
+
const index = Number.isInteger(params.contentIndex) ? params.contentIndex : 0;
|
|
1022
|
+
item.content = growArray(item.content, index, "");
|
|
1023
|
+
item.content[index] = `${item.content[index] || ""}${delta}`;
|
|
1024
|
+
}
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (method === "item/fileChange/outputDelta") {
|
|
1029
|
+
const item = ensureItemOfType(turn, itemId, () => ({
|
|
1030
|
+
type: "fileChange",
|
|
1031
|
+
id: itemId,
|
|
1032
|
+
changes: [],
|
|
1033
|
+
status: "inProgress",
|
|
1034
|
+
aggregatedOutput: "",
|
|
1035
|
+
}));
|
|
1036
|
+
item.aggregatedOutput = `${item.aggregatedOutput || ""}${delta}`;
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const item = ensureItemOfType(turn, itemId, () => ({
|
|
1041
|
+
type: "commandExecution",
|
|
1042
|
+
id: itemId,
|
|
1043
|
+
command: "",
|
|
1044
|
+
cwd: conversation.cwd || "/",
|
|
1045
|
+
processId: null,
|
|
1046
|
+
source: "exec",
|
|
1047
|
+
status: "inProgress",
|
|
1048
|
+
commandActions: [],
|
|
1049
|
+
aggregatedOutput: "",
|
|
1050
|
+
exitCode: null,
|
|
1051
|
+
durationMs: null,
|
|
1052
|
+
}));
|
|
1053
|
+
item.aggregatedOutput = `${item.aggregatedOutput || ""}${delta}`;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function ensureItemOfType(turn, itemId, createItem) {
|
|
1057
|
+
let item = turn.items.find((candidate) => readString(candidate?.id) === itemId);
|
|
1058
|
+
if (!item) {
|
|
1059
|
+
item = createItem();
|
|
1060
|
+
turn.items.push(item);
|
|
1061
|
+
}
|
|
1062
|
+
return item;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function growArray(value, index, fillValue) {
|
|
1066
|
+
const next = Array.isArray(value) ? value : [];
|
|
1067
|
+
while (next.length <= index) {
|
|
1068
|
+
next.push(fillValue);
|
|
1069
|
+
}
|
|
1070
|
+
return next;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function isFileChangeLikeItemType(value) {
|
|
1074
|
+
const itemType = normalizeToken(value);
|
|
1075
|
+
return itemType === "filechange" || itemType === "diff";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function readThreadIdFromParams(params) {
|
|
1079
|
+
return readString(params?.threadId)
|
|
1080
|
+
|| readString(params?.thread_id)
|
|
1081
|
+
|| readString(params?.conversationId)
|
|
1082
|
+
|| readString(params?.conversation_id)
|
|
1083
|
+
|| readString(params?.turn?.threadId)
|
|
1084
|
+
|| readString(params?.turn?.thread_id)
|
|
1085
|
+
|| readString(params?.thread?.id);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function readTurnIdFromParams(params) {
|
|
1089
|
+
return readString(params?.turnId)
|
|
1090
|
+
|| readString(params?.turn_id)
|
|
1091
|
+
|| readString(params?.turn?.id)
|
|
1092
|
+
|| readString(params?.turn?.turnId)
|
|
1093
|
+
|| readString(params?.turn?.turn_id);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function readTurnIdFromTurn(turn) {
|
|
1097
|
+
return readString(turn?.id)
|
|
1098
|
+
|| readString(turn?.turnId)
|
|
1099
|
+
|| readString(turn?.turn_id);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function timestampSecondsToMs(value) {
|
|
1103
|
+
return Number.isFinite(value) && value > 0 ? Math.round(value * 1000) : 0;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const REQUEST_METHODS_WITH_THREAD = new Set([
|
|
1107
|
+
"item/commandExecution/requestApproval",
|
|
1108
|
+
"item/fileChange/requestApproval",
|
|
1109
|
+
"item/fileRead/requestApproval",
|
|
1110
|
+
"item/permissions/requestApproval",
|
|
1111
|
+
"item/tool/requestUserInput",
|
|
1112
|
+
"mcpServer/elicitation/request",
|
|
1113
|
+
"item/tool/call",
|
|
1114
|
+
]);
|
|
1115
|
+
|
|
1116
|
+
module.exports = {
|
|
1117
|
+
LOCAL_HOST_ID,
|
|
1118
|
+
REQUEST_METHODS_WITH_THREAD,
|
|
1119
|
+
applyAppServerMessageToConversationState,
|
|
1120
|
+
applyPendingTurnStartParams,
|
|
1121
|
+
buildConversationStateFromThread,
|
|
1122
|
+
buildConversationTurn,
|
|
1123
|
+
createEmptyConversationState,
|
|
1124
|
+
ensureConversationInMap,
|
|
1125
|
+
mergeConversationTurnsFromThread,
|
|
1126
|
+
readThreadIdFromParams,
|
|
1127
|
+
readTurnIdFromParams,
|
|
1128
|
+
readTurnIdFromTurn,
|
|
1129
|
+
timestampSecondsToMs,
|
|
1130
|
+
upsertItem,
|
|
1131
|
+
upsertTurn,
|
|
1132
|
+
};
|