lunel-cli 0.1.114 → 0.1.116

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/dist/ai/codex.js CHANGED
@@ -6,6 +6,18 @@ import * as path from "path";
6
6
  import { spawn } from "child_process";
7
7
  import { createInterface } from "readline";
8
8
  const THREAD_LIST_SOURCE_KINDS = ["cli", "vscode", "appServer", "exec", "unknown"];
9
+ const CODEX_AGENTS = [
10
+ {
11
+ name: "Build",
12
+ mode: "default",
13
+ description: "Default Codex collaboration mode.",
14
+ },
15
+ {
16
+ name: "plan",
17
+ mode: "plan",
18
+ description: "Plan-first Codex collaboration mode.",
19
+ },
20
+ ];
9
21
  const DEBUG_MODE = process.env.LUNEL_DEBUG === "1" || process.env.LUNEL_DEBUG_AI === "1";
10
22
  function joinStreamingText(previousText, nextChunk) {
11
23
  if (!previousText) {
@@ -29,14 +41,22 @@ export class CodexProvider {
29
41
  proc = null;
30
42
  shuttingDown = false;
31
43
  emitter = null;
44
+ defaultModelContextWindow = null;
32
45
  nextId = 1;
33
46
  pending = new Map();
34
47
  sessions = new Map();
35
48
  deletedThreadIds = new Set();
36
49
  resumedThreadIds = new Set();
37
50
  pendingPermissionRequestIds = new Map();
51
+ pendingQuestionRequestIds = new Map();
38
52
  assistantMessageIdByTurnId = new Map();
39
53
  partTextById = new Map();
54
+ debugLog(message, fields) {
55
+ if (!DEBUG_MODE)
56
+ return;
57
+ const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
58
+ console.log(`[codex] ${message}${suffix}`);
59
+ }
40
60
  async init() {
41
61
  if (DEBUG_MODE)
42
62
  console.log("Starting Codex app-server...");
@@ -61,7 +81,15 @@ export class CodexProvider {
61
81
  this.emitter?.({ type: "sse_dead", properties: { error: msg } });
62
82
  }
63
83
  });
64
- await this.call("initialize", { clientInfo: { name: "lunel", version: "1.0" } });
84
+ await this.call("initialize", {
85
+ clientInfo: { name: "lunel", version: "1.0" },
86
+ capabilities: {
87
+ experimentalApi: true,
88
+ },
89
+ });
90
+ await this.refreshConfigDefaults().catch(() => {
91
+ // Config defaults are best-effort metadata only.
92
+ });
65
93
  if (DEBUG_MODE)
66
94
  console.log("Codex ready.\n");
67
95
  }
@@ -78,7 +106,10 @@ export class CodexProvider {
78
106
  };
79
107
  }
80
108
  async createSession(title) {
81
- const result = await this.call("thread/start", { cwd: process.cwd() });
109
+ const result = await this.call("thread/start", {
110
+ cwd: process.cwd(),
111
+ persistExtendedHistory: true,
112
+ });
82
113
  const threadObject = this.extractThreadObject(result);
83
114
  const threadId = this.extractThreadId(threadObject);
84
115
  if (!threadId) {
@@ -143,8 +174,53 @@ export class CodexProvider {
143
174
  }
144
175
  return { deleted: true };
145
176
  }
177
+ async renameSession(id, title) {
178
+ const trimmed = title.trim();
179
+ if (!trimmed) {
180
+ throw new Error("Session title cannot be empty");
181
+ }
182
+ const methodsToTry = [
183
+ { method: "thread/name/set", params: { threadId: id, name: trimmed } },
184
+ { method: "thread/name/set", params: { threadId: id, title: trimmed } },
185
+ { method: "thread/name/update", params: { threadId: id, name: trimmed } },
186
+ { method: "thread/name/update", params: { threadId: id, title: trimmed } },
187
+ { method: "thread/metadata/update", params: { threadId: id, title: trimmed } },
188
+ { method: "thread/update", params: { threadId: id, title: trimmed } },
189
+ { method: "thread/rename", params: { threadId: id, title: trimmed } },
190
+ ];
191
+ let renamed = false;
192
+ let lastError = null;
193
+ for (const entry of methodsToTry) {
194
+ try {
195
+ await this.call(entry.method, entry.params);
196
+ renamed = true;
197
+ break;
198
+ }
199
+ catch (err) {
200
+ lastError = err;
201
+ }
202
+ }
203
+ if (!renamed && lastError) {
204
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
205
+ }
206
+ const existing = this.sessions.get(id) ?? this.ensureLocalSession(id);
207
+ const session = this.upsertSession({
208
+ id,
209
+ title: trimmed,
210
+ createdAt: existing.createdAt,
211
+ updatedAt: Date.now(),
212
+ archived: existing.archived,
213
+ cwd: existing.cwd,
214
+ }, true);
215
+ return { session: this.toSessionInfo(session) };
216
+ }
146
217
  async getMessages(sessionId) {
147
218
  const session = this.ensureLocalSession(sessionId);
219
+ this.debugLog("getMessages start", {
220
+ sessionId,
221
+ existingMessageCount: session.messages.length,
222
+ existingPartCount: session.messages.reduce((sum, message) => sum + (message.parts?.length || 0), 0),
223
+ });
148
224
  const result = await this.call("thread/read", {
149
225
  threadId: session.id,
150
226
  includeTurns: true,
@@ -153,7 +229,16 @@ export class CodexProvider {
153
229
  if (!threadObject) {
154
230
  return { messages: session.messages };
155
231
  }
232
+ this.logThreadReadSummary(sessionId, threadObject);
156
233
  const historyMessages = this.decodeMessagesFromThreadRead(sessionId, threadObject);
234
+ this.debugLog("getMessages decoded thread", {
235
+ sessionId,
236
+ turnCount: this.readArray(threadObject.turns).length,
237
+ decodedMessageCount: historyMessages.length,
238
+ decodedPartCount: historyMessages.reduce((sum, message) => sum + (message.parts?.length || 0), 0),
239
+ decodedRoles: historyMessages.map((message) => message.role),
240
+ decodedPartTypes: historyMessages.flatMap((message) => (message.parts || []).map((part) => String(this.asRecord(part).type ?? "unknown"))),
241
+ });
157
242
  if (historyMessages.length > 0) {
158
243
  session.messages = historyMessages;
159
244
  }
@@ -165,6 +250,17 @@ export class CodexProvider {
165
250
  archived: false,
166
251
  cwd: this.extractThreadCwd(threadObject) ?? session.cwd,
167
252
  });
253
+ if (this.defaultModelContextWindow && this.defaultModelContextWindow > 0) {
254
+ this.emitter?.({
255
+ type: "session.usage",
256
+ properties: {
257
+ sessionID: sessionId,
258
+ tokenUsage: {
259
+ modelContextWindow: this.defaultModelContextWindow,
260
+ },
261
+ },
262
+ });
263
+ }
168
264
  return { messages: session.messages };
169
265
  }
170
266
  async prompt(sessionId, text, model, agent, files = [], codexOptions) {
@@ -172,26 +268,31 @@ export class CodexProvider {
172
268
  session.updatedAt = Date.now();
173
269
  (async () => {
174
270
  try {
175
- await this.ensureThreadResumed(session.id);
271
+ const effortLevels = ["low", "medium", "high"];
272
+ const speedDelta = {
273
+ fast: -1,
274
+ balanced: 0,
275
+ quality: 1,
276
+ };
277
+ const baseEffort = codexOptions?.reasoningEffort ?? "medium";
278
+ const baseIndex = effortLevels.indexOf(baseEffort);
279
+ const adjustedIndex = Math.max(0, Math.min(effortLevels.length - 1, baseIndex + (codexOptions?.speed ? speedDelta[codexOptions.speed] : 0)));
280
+ const reasoningEffort = effortLevels[adjustedIndex];
281
+ const modelId = await this.resolveModelId(model);
282
+ const collaborationMode = this.buildCollaborationMode(agent, modelId, reasoningEffort);
283
+ // Freshly created sessions already have a live backend thread from
284
+ // thread/start. Forcing thread/resume here can attach stale state to a
285
+ // brand-new session and trigger rollout lookup failures on turn/start.
286
+ await this.ensureThreadResumed(session.id, false, codexOptions, collaborationMode);
176
287
  let imageUrlKey = "url";
177
288
  while (true) {
178
289
  try {
179
- const effortLevels = ["low", "medium", "high"];
180
- const speedDelta = {
181
- fast: -1,
182
- balanced: 0,
183
- quality: 1,
184
- };
185
- const baseEffort = codexOptions?.reasoningEffort ?? "medium";
186
- const baseIndex = effortLevels.indexOf(baseEffort);
187
- const adjustedIndex = Math.max(0, Math.min(effortLevels.length - 1, baseIndex + (codexOptions?.speed ? speedDelta[codexOptions.speed] : 0)));
188
- const reasoningEffort = effortLevels[adjustedIndex];
189
290
  await this.call("turn/start", {
190
291
  threadId: session.id,
191
292
  input: this.makeTurnInputPayload(text, files, imageUrlKey),
192
- ...(model ? { model: model.providerID === "codex" ? model.modelID : `${model.providerID}/${model.modelID}` } : {}),
193
- ...(agent ? { agent } : {}),
293
+ ...(modelId ? { model: modelId } : {}),
194
294
  ...(reasoningEffort ? { reasoningEffort } : {}),
295
+ ...(collaborationMode ? { collaborationMode } : {}),
195
296
  });
196
297
  break;
197
298
  }
@@ -225,7 +326,7 @@ export class CodexProvider {
225
326
  return {};
226
327
  }
227
328
  async agents() {
228
- return { agents: [] };
329
+ return { agents: [...CODEX_AGENTS] };
229
330
  }
230
331
  async providers() {
231
332
  const items = await this.fetchModels();
@@ -283,11 +384,53 @@ export class CodexProvider {
283
384
  });
284
385
  return {};
285
386
  }
286
- async questionReply() {
287
- throw new Error("Codex structured user input is not supported by Lunel yet");
387
+ async questionReply(sessionId, questionId, answers) {
388
+ const pending = this.pendingQuestionRequestIds.get(questionId);
389
+ if (!pending) {
390
+ this.emitter?.({
391
+ type: "question.replied",
392
+ properties: { sessionID: sessionId, requestID: questionId, answers, skipped: true },
393
+ });
394
+ return {};
395
+ }
396
+ const responseAnswers = {};
397
+ pending.questionIds.forEach((id, index) => {
398
+ responseAnswers[id] = {
399
+ answers: Array.isArray(answers[index]) ? answers[index].filter((value) => typeof value === "string") : [],
400
+ };
401
+ });
402
+ this.send({
403
+ jsonrpc: "2.0",
404
+ id: pending.requestId,
405
+ result: { answers: responseAnswers },
406
+ });
407
+ this.pendingQuestionRequestIds.delete(questionId);
408
+ this.emitter?.({
409
+ type: "question.replied",
410
+ properties: { sessionID: sessionId, requestID: questionId, answers },
411
+ });
412
+ return {};
288
413
  }
289
- async questionReject() {
290
- throw new Error("Codex structured user input is not supported by Lunel yet");
414
+ async questionReject(sessionId, questionId) {
415
+ const pending = this.pendingQuestionRequestIds.get(questionId);
416
+ if (!pending) {
417
+ this.emitter?.({
418
+ type: "question.rejected",
419
+ properties: { sessionID: sessionId, requestID: questionId, skipped: true },
420
+ });
421
+ return {};
422
+ }
423
+ this.send({
424
+ jsonrpc: "2.0",
425
+ id: pending.requestId,
426
+ result: { answers: {} },
427
+ });
428
+ this.pendingQuestionRequestIds.delete(questionId);
429
+ this.emitter?.({
430
+ type: "question.rejected",
431
+ properties: { sessionID: sessionId, requestID: questionId },
432
+ });
433
+ return {};
291
434
  }
292
435
  send(req) {
293
436
  if (!this.proc?.stdin?.writable)
@@ -344,10 +487,28 @@ export class CodexProvider {
344
487
  handleServerRequest(method, requestId, params) {
345
488
  const session = this.resolveSessionFromPayload(params);
346
489
  if (method === "item/tool/requestUserInput") {
347
- this.send({
348
- jsonrpc: "2.0",
349
- id: requestId,
350
- error: { code: -32601, message: "Structured user input is not supported by Lunel yet" },
490
+ const questionRequestId = String(requestId);
491
+ const callId = this.extractItemId(params) ?? undefined;
492
+ const messageId = session ? this.ensureAssistantMessage(session, this.extractTurnId(params) ?? `session:${session.id}`) : undefined;
493
+ const questions = this.extractStructuredUserInputQuestions(params);
494
+ this.pendingQuestionRequestIds.set(questionRequestId, {
495
+ sessionId: session?.id ?? "",
496
+ requestId,
497
+ messageId,
498
+ callId,
499
+ questionIds: questions.map((question) => this.readString(this.asRecord(question).id)).filter((value) => Boolean(value)),
500
+ });
501
+ this.emitter?.({
502
+ type: "question.asked",
503
+ properties: {
504
+ id: questionRequestId,
505
+ sessionID: session?.id,
506
+ questions,
507
+ tool: {
508
+ ...(messageId ? { messageID: messageId } : {}),
509
+ ...(callId ? { callID: callId } : {}),
510
+ },
511
+ },
351
512
  });
352
513
  return;
353
514
  }
@@ -427,6 +588,17 @@ export class CodexProvider {
427
588
  });
428
589
  }
429
590
  return;
591
+ case "thread/tokenUsage/updated":
592
+ if (session) {
593
+ this.emitter?.({
594
+ type: "session.usage",
595
+ properties: {
596
+ sessionID: session.id,
597
+ tokenUsage: params.tokenUsage ?? null,
598
+ },
599
+ });
600
+ }
601
+ return;
430
602
  case "turn/completed":
431
603
  case "turn/failed":
432
604
  if (session) {
@@ -503,6 +675,9 @@ export class CodexProvider {
503
675
  this.pendingPermissionRequestIds.delete(permissionId);
504
676
  this.emitter?.({ type: "permission.replied", properties: { permissionId } });
505
677
  }
678
+ if (permissionId && this.pendingQuestionRequestIds.has(permissionId)) {
679
+ this.pendingQuestionRequestIds.delete(permissionId);
680
+ }
506
681
  return;
507
682
  }
508
683
  default:
@@ -621,7 +796,7 @@ export class CodexProvider {
621
796
  const itemId = this.extractItemId(params) ?? this.readString(item.id) ?? normalizedType ?? "tool";
622
797
  const fileChangeLike = this.isFileChangeStructuredItem(normalizedType, item, params);
623
798
  const emittedPartType = normalizedType === "plan"
624
- ? "reasoning"
799
+ ? "plan"
625
800
  : (fileChangeLike ? "file-change" : "tool");
626
801
  const partId = `${messageId}:${emittedPartType}:${itemId}`;
627
802
  const nextText = this.extractStructuredOutput(params, item, normalizedType);
@@ -644,8 +819,8 @@ export class CodexProvider {
644
819
  sessionID: session.id,
645
820
  messageID: messageId,
646
821
  type: emittedPartType,
647
- ...(emittedPartType === "reasoning"
648
- ? { text: outputValue ?? "Planning..." }
822
+ ...(emittedPartType === "plan"
823
+ ? { text: outputValue ?? (emittedPartType === "plan" ? "Planning..." : "") }
649
824
  : { name, toolName: name, input, output: outputValue, state, ...(patch ? { patch } : {}) }),
650
825
  };
651
826
  this.upsertLocalMessagePart(session, messageId, part);
@@ -720,6 +895,22 @@ export class CodexProvider {
720
895
  async fetchServerThreads() {
721
896
  return this.fetchServerThreadsByArchiveState(false);
722
897
  }
898
+ async refreshConfigDefaults() {
899
+ const result = await this.call("config/read", undefined);
900
+ const payload = this.asRecord(result);
901
+ const config = this.asRecord(payload.config ?? result);
902
+ const raw = config.model_context_window;
903
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
904
+ this.defaultModelContextWindow = raw;
905
+ return;
906
+ }
907
+ if (typeof raw === "string" && raw.trim()) {
908
+ const parsed = Number(raw);
909
+ if (Number.isFinite(parsed) && parsed > 0) {
910
+ this.defaultModelContextWindow = parsed;
911
+ }
912
+ }
913
+ }
723
914
  async refreshSessionMetadata(sessionId) {
724
915
  const session = this.sessions.get(sessionId);
725
916
  const result = await this.call("thread/read", {
@@ -800,6 +991,30 @@ export class CodexProvider {
800
991
  })
801
992
  .filter((value) => Boolean(value));
802
993
  }
994
+ async resolveModelId(model) {
995
+ if (model) {
996
+ return model.providerID === "codex" ? model.modelID : `${model.providerID}/${model.modelID}`;
997
+ }
998
+ const items = await this.fetchModels();
999
+ return items.find((item) => item.isDefault)?.model ?? items[0]?.model;
1000
+ }
1001
+ buildCollaborationMode(agent, modelId, reasoningEffort) {
1002
+ const normalizedAgent = (agent ?? "").trim().toLowerCase();
1003
+ const mode = normalizedAgent === "build" ? "default" : normalizedAgent;
1004
+ if (mode !== "default" && mode !== "plan") {
1005
+ return undefined;
1006
+ }
1007
+ if (!modelId) {
1008
+ return undefined;
1009
+ }
1010
+ return {
1011
+ mode,
1012
+ settings: {
1013
+ model: modelId,
1014
+ reasoning_effort: reasoningEffort,
1015
+ },
1016
+ };
1017
+ }
803
1018
  parseThreadListEntry(value, archived = false) {
804
1019
  const obj = this.asRecord(value);
805
1020
  const id = this.extractThreadId(obj);
@@ -909,16 +1124,27 @@ export class CodexProvider {
909
1124
  return existing;
910
1125
  return this.upsertSession({ id: sessionId, title: "Conversation", createdAt: Date.now(), updatedAt: Date.now() });
911
1126
  }
912
- async ensureThreadResumed(threadId, force = false) {
1127
+ async ensureThreadResumed(threadId, force = false, codexOptions, collaborationMode) {
913
1128
  if (!threadId || this.resumedThreadIds.has(threadId)) {
914
1129
  if (!force)
915
1130
  return;
916
1131
  }
917
1132
  const session = this.sessions.get(threadId);
918
- const params = { threadId };
1133
+ const params = {
1134
+ threadId,
1135
+ persistExtendedHistory: true,
1136
+ };
919
1137
  if (session?.cwd) {
920
1138
  params.cwd = session.cwd;
921
1139
  }
1140
+ const permissionMode = codexOptions?.permissionMode ?? "default";
1141
+ if (permissionMode === "full-access") {
1142
+ params.approvalPolicy = "never";
1143
+ params.sandbox = "danger-full-access";
1144
+ }
1145
+ if (collaborationMode) {
1146
+ params.collaborationMode = collaborationMode;
1147
+ }
922
1148
  const result = await this.call("thread/resume", params);
923
1149
  const payload = this.asRecord(result);
924
1150
  const threadValue = payload.thread;
@@ -971,9 +1197,14 @@ export class CodexProvider {
971
1197
  const turnId = this.readString(turnObject.id);
972
1198
  const turnTime = this.extractUpdatedAt(turnObject) ?? this.extractCreatedAt(turnObject) ?? Date.now();
973
1199
  const items = this.readArray(turnObject.items);
1200
+ const assistantParts = [];
1201
+ let assistantMessageId;
1202
+ let assistantTimestamp = turnTime;
1203
+ const turnItemTypes = [];
974
1204
  for (const item of items) {
975
1205
  const itemObject = this.asRecord(item);
976
1206
  const type = this.normalizedItemType(this.readString(itemObject.type) ?? "");
1207
+ turnItemTypes.push(type || "unknown");
977
1208
  const itemId = this.readString(itemObject.id) ?? crypto.randomUUID();
978
1209
  const timestamp = this.extractUpdatedAt(itemObject) ?? this.extractCreatedAt(itemObject) ?? (turnTime + orderOffset++);
979
1210
  if (type === "usermessage") {
@@ -992,88 +1223,256 @@ export class CodexProvider {
992
1223
  const text = this.decodeItemText(itemObject);
993
1224
  if (!text)
994
1225
  continue;
995
- messages.push({
996
- id: itemId,
997
- role: "assistant",
998
- parts: [{ id: `${itemId}:text`, type: "text", text, sessionID: threadId, messageID: itemId }],
999
- time: timestamp,
1226
+ assistantMessageId = assistantMessageId ?? itemId;
1227
+ assistantTimestamp = Math.max(assistantTimestamp, timestamp);
1228
+ assistantParts.push({
1229
+ id: `${assistantMessageId}:text:${itemId}`,
1230
+ type: "text",
1231
+ text,
1232
+ sessionID: threadId,
1233
+ messageID: assistantMessageId,
1000
1234
  });
1001
- if (turnId)
1002
- this.assistantMessageIdByTurnId.set(turnId, itemId);
1003
1235
  continue;
1004
1236
  }
1005
1237
  if (type === "reasoning") {
1006
1238
  const text = this.decodeReasoningItemText(itemObject);
1007
- messages.push({
1008
- id: itemId,
1009
- role: "assistant",
1010
- parts: [{ id: `${itemId}:reasoning`, type: "reasoning", text, sessionID: threadId, messageID: itemId }],
1011
- time: timestamp,
1239
+ assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
1240
+ assistantTimestamp = Math.max(assistantTimestamp, timestamp);
1241
+ assistantParts.push({
1242
+ id: `${assistantMessageId}:reasoning:${itemId}`,
1243
+ type: "reasoning",
1244
+ text,
1245
+ sessionID: threadId,
1246
+ messageID: assistantMessageId,
1012
1247
  });
1013
1248
  continue;
1014
1249
  }
1015
1250
  if (type === "plan") {
1016
1251
  const text = this.decodePlanItemText(itemObject);
1017
- messages.push({
1018
- id: itemId,
1019
- role: "assistant",
1020
- parts: [{ id: `${itemId}:plan`, type: "reasoning", text, sessionID: threadId, messageID: itemId }],
1021
- time: timestamp,
1252
+ assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
1253
+ assistantTimestamp = Math.max(assistantTimestamp, timestamp);
1254
+ assistantParts.push({
1255
+ id: `${assistantMessageId}:plan:${itemId}`,
1256
+ type: "plan",
1257
+ text,
1258
+ sessionID: threadId,
1259
+ messageID: assistantMessageId,
1022
1260
  });
1023
1261
  continue;
1024
1262
  }
1025
- if (type === "commandexecution" || type === "enteredreviewmode" || type === "contextcompaction") {
1026
- const output = this.decodeCommandExecutionItemText(itemObject, type);
1027
- const input = this.extractCommandExecutionInput(itemObject);
1028
- messages.push({
1029
- id: itemId,
1030
- role: "assistant",
1031
- parts: [{
1032
- id: `${itemId}:tool`,
1033
- type: "tool",
1034
- name: type === "commandexecution" ? "command" : "system",
1035
- toolName: type === "commandexecution" ? "command" : "system",
1036
- ...(input ? { input } : {}),
1037
- output,
1038
- state: "completed",
1039
- sessionID: threadId,
1040
- messageID: itemId,
1041
- }],
1042
- time: timestamp,
1043
- });
1263
+ if (type === "commandexecution"
1264
+ || type === "enteredreviewmode"
1265
+ || type === "exitedreviewmode"
1266
+ || type === "contextcompaction"
1267
+ || type === "mcptoolcall"
1268
+ || type === "dynamictoolcall"
1269
+ || type === "collabtoolcall"
1270
+ || type === "collabagenttoolcall"
1271
+ || type === "websearch"
1272
+ || type === "imageview") {
1273
+ assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
1274
+ assistantTimestamp = Math.max(assistantTimestamp, timestamp);
1275
+ assistantParts.push(this.decodeStoredToolLikePart(type, itemObject, threadId, assistantMessageId, itemId));
1044
1276
  continue;
1045
1277
  }
1046
1278
  if (type === "filechange" || type === "toolcall" || type === "diff") {
1047
- const output = this.decodeFileLikeItemText(itemObject);
1048
- if (!output)
1049
- continue;
1050
- const emittedPartType = this.isFileChangeStructuredItem(type, itemObject)
1051
- ? "file-change"
1052
- : "tool";
1053
- const patch = emittedPartType === "file-change"
1054
- ? this.extractCanonicalPatch({}, itemObject)
1055
- : undefined;
1056
- messages.push({
1057
- id: itemId,
1058
- role: "assistant",
1059
- parts: [{
1060
- id: `${itemId}:${emittedPartType}`,
1061
- type: emittedPartType,
1062
- name: type,
1063
- toolName: type,
1064
- output,
1065
- state: "completed",
1066
- ...(patch ? { patch } : {}),
1067
- sessionID: threadId,
1068
- messageID: itemId,
1069
- }],
1070
- time: timestamp,
1071
- });
1279
+ assistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : itemId);
1280
+ assistantTimestamp = Math.max(assistantTimestamp, timestamp);
1281
+ assistantParts.push(this.decodeStoredToolLikePart(type, itemObject, threadId, assistantMessageId, itemId));
1282
+ }
1283
+ }
1284
+ if (assistantParts.length > 0) {
1285
+ const resolvedAssistantMessageId = assistantMessageId ?? (turnId ? `assistant:${turnId}` : crypto.randomUUID());
1286
+ messages.push({
1287
+ id: resolvedAssistantMessageId,
1288
+ role: "assistant",
1289
+ parts: assistantParts.map((part) => ({
1290
+ ...part,
1291
+ sessionID: threadId,
1292
+ messageID: resolvedAssistantMessageId,
1293
+ })),
1294
+ time: assistantTimestamp,
1295
+ });
1296
+ if (turnId) {
1297
+ this.assistantMessageIdByTurnId.set(turnId, resolvedAssistantMessageId);
1072
1298
  }
1073
1299
  }
1300
+ this.debugLog("decoded stored turn", {
1301
+ threadId,
1302
+ turnId: turnId ?? null,
1303
+ itemCount: items.length,
1304
+ itemTypes: turnItemTypes,
1305
+ emittedUserMessages: messages.filter((message) => message.role === "user").length,
1306
+ emittedAssistantParts: assistantParts.length,
1307
+ });
1074
1308
  }
1075
1309
  return messages;
1076
1310
  }
1311
+ logThreadReadSummary(sessionId, threadObject) {
1312
+ const turns = this.readArray(threadObject.turns);
1313
+ const turnSummaries = turns.map((turn, index) => {
1314
+ const turnObject = this.asRecord(turn);
1315
+ const items = this.readArray(turnObject.items);
1316
+ return {
1317
+ index,
1318
+ turnId: this.readString(turnObject.id) ?? null,
1319
+ status: this.readString(turnObject.status) ?? this.readString(this.asRecord(turnObject.status).type) ?? null,
1320
+ itemCount: items.length,
1321
+ itemTypes: items.map((item) => this.normalizedItemType(this.readString(this.asRecord(item).type) ?? "") || "unknown"),
1322
+ itemSummaries: items.map((item) => {
1323
+ const itemObject = this.asRecord(item);
1324
+ const type = this.normalizedItemType(this.readString(itemObject.type) ?? "") || "unknown";
1325
+ return {
1326
+ id: this.readString(itemObject.id) ?? null,
1327
+ type,
1328
+ keys: Object.keys(itemObject).sort(),
1329
+ textPreview: this.firstString(itemObject, ["text", "summary", "message", "query", "path", "tool", "command"])?.slice(0, 120) ?? null,
1330
+ hasAggregatedOutput: typeof itemObject.aggregatedOutput === "string" && itemObject.aggregatedOutput.length > 0,
1331
+ aggregatedOutputLength: typeof itemObject.aggregatedOutput === "string" ? itemObject.aggregatedOutput.length : 0,
1332
+ changesCount: Array.isArray(itemObject.changes) ? itemObject.changes.length : 0,
1333
+ hasResult: itemObject.result != null,
1334
+ hasContentItems: Array.isArray(itemObject.contentItems) && itemObject.contentItems.length > 0,
1335
+ status: this.readString(itemObject.status) ?? null,
1336
+ };
1337
+ }),
1338
+ };
1339
+ });
1340
+ console.log(`[codex-history] thread/read summary ${JSON.stringify({
1341
+ sessionId,
1342
+ threadId: this.readString(threadObject.id) ?? null,
1343
+ turnCount: turns.length,
1344
+ turnSummaries,
1345
+ })}`);
1346
+ }
1347
+ decodeStoredToolLikePart(type, itemObject, threadId, messageId, itemId) {
1348
+ if (type === "filechange" || type === "diff" || this.isFileChangeStructuredItem(type, itemObject)) {
1349
+ const output = this.decodeFileLikeItemText(itemObject) ?? this.describeCompletedItemOutput(itemObject, itemObject, type) ?? "File changes";
1350
+ const patch = this.extractCanonicalPatch(itemObject, itemObject);
1351
+ return {
1352
+ id: `${messageId}:file-change:${itemId}`,
1353
+ type: "file-change",
1354
+ name: this.describeToolPart(type, type, itemObject),
1355
+ toolName: this.describeToolPart(type, type, itemObject),
1356
+ output,
1357
+ state: "completed",
1358
+ ...(patch ? { patch } : {}),
1359
+ sessionID: threadId,
1360
+ messageID: messageId,
1361
+ };
1362
+ }
1363
+ const name = this.describeStoredToolName(type, itemObject);
1364
+ const input = this.extractStoredToolInput(type, itemObject);
1365
+ const output = this.extractStoredToolOutput(type, itemObject);
1366
+ return {
1367
+ id: `${messageId}:tool:${itemId}`,
1368
+ type: "tool",
1369
+ name,
1370
+ toolName: name,
1371
+ ...(input !== undefined ? { input } : {}),
1372
+ ...(output !== undefined ? { output } : {}),
1373
+ state: "completed",
1374
+ sessionID: threadId,
1375
+ messageID: messageId,
1376
+ };
1377
+ }
1378
+ describeStoredToolName(type, itemObject) {
1379
+ if (type === "commandexecution") {
1380
+ return "command";
1381
+ }
1382
+ if (type === "websearch") {
1383
+ return "web-search";
1384
+ }
1385
+ if (type === "imageview") {
1386
+ return "image-view";
1387
+ }
1388
+ if (type === "collabtoolcall" || type === "collabagenttoolcall") {
1389
+ return "agent";
1390
+ }
1391
+ if (type === "enteredreviewmode") {
1392
+ return "review";
1393
+ }
1394
+ if (type === "exitedreviewmode") {
1395
+ return "review";
1396
+ }
1397
+ if (type === "contextcompaction") {
1398
+ return "context";
1399
+ }
1400
+ return this.firstString(itemObject, [
1401
+ "name",
1402
+ "toolName",
1403
+ "tool_name",
1404
+ "title",
1405
+ "serverToolName",
1406
+ "server_tool_name",
1407
+ "kind",
1408
+ ]) ?? type;
1409
+ }
1410
+ extractStoredToolInput(type, itemObject) {
1411
+ if (type === "commandexecution") {
1412
+ return this.extractCommandExecutionInput(itemObject, itemObject);
1413
+ }
1414
+ return itemObject.input
1415
+ ?? itemObject.arguments
1416
+ ?? itemObject.args
1417
+ ?? itemObject.query
1418
+ ?? itemObject.path
1419
+ ?? itemObject.url
1420
+ ?? itemObject.command
1421
+ ?? itemObject.pattern
1422
+ ?? undefined;
1423
+ }
1424
+ extractStoredToolOutput(type, itemObject) {
1425
+ if (type === "commandexecution") {
1426
+ return this.decodeStoredCommandExecutionOutput(itemObject);
1427
+ }
1428
+ if (type === "enteredreviewmode") {
1429
+ return `Reviewing ${this.readString(itemObject.review) ?? "changes"}...`;
1430
+ }
1431
+ if (type === "exitedreviewmode") {
1432
+ return this.firstString(itemObject, ["summary", "text", "message"]) ?? "Exited review mode";
1433
+ }
1434
+ if (type === "contextcompaction") {
1435
+ return "Context compacted";
1436
+ }
1437
+ if (type === "imageview") {
1438
+ const path = this.firstString(itemObject, ["path", "file", "filePath", "file_path", "url"]);
1439
+ return path ? `Viewed image ${path}` : "Viewed image";
1440
+ }
1441
+ const direct = this.firstString(itemObject, [
1442
+ "aggregatedOutput",
1443
+ "output",
1444
+ "outputText",
1445
+ "output_text",
1446
+ "text",
1447
+ "message",
1448
+ "summary",
1449
+ "result",
1450
+ "content",
1451
+ ]);
1452
+ if (direct?.trim()) {
1453
+ return direct.trim();
1454
+ }
1455
+ const flattened = this.flattenTextValue(itemObject.output ?? itemObject.result ?? itemObject.content).trim();
1456
+ return flattened || undefined;
1457
+ }
1458
+ decodeStoredCommandExecutionOutput(itemObject) {
1459
+ const aggregatedOutput = this.firstString(itemObject, [
1460
+ "aggregatedOutput",
1461
+ "aggregated_output",
1462
+ "output",
1463
+ "outputText",
1464
+ "output_text",
1465
+ "stdout",
1466
+ "stderr",
1467
+ "text",
1468
+ "message",
1469
+ "summary",
1470
+ ]);
1471
+ if (aggregatedOutput?.trim()) {
1472
+ return aggregatedOutput.trim();
1473
+ }
1474
+ return this.decodeCommandExecutionItemText(itemObject, "commandexecution");
1475
+ }
1077
1476
  decodeUserMessageParts(itemObject, threadId, itemId) {
1078
1477
  const parts = [];
1079
1478
  const content = this.readArray(itemObject.content);
@@ -1306,6 +1705,41 @@ export class CodexProvider {
1306
1705
  ?? this.readRawString(this.asRecord(payload.event).delta)
1307
1706
  ?? this.readRawString(this.asRecord(payload.event).message));
1308
1707
  }
1708
+ extractStructuredUserInputQuestions(payload) {
1709
+ const rawQuestions = this.readArray(payload.questions);
1710
+ const questions = [];
1711
+ for (const value of rawQuestions) {
1712
+ const question = this.asRecord(value);
1713
+ const options = this.readArray(question.options)
1714
+ .map((option) => {
1715
+ const optionObject = this.asRecord(option);
1716
+ const label = this.readString(optionObject.label);
1717
+ const description = this.readString(optionObject.description);
1718
+ if (!label)
1719
+ return undefined;
1720
+ return {
1721
+ label,
1722
+ ...(description ? { description } : {}),
1723
+ };
1724
+ })
1725
+ .filter((value) => Boolean(value));
1726
+ const id = this.readString(question.id);
1727
+ const header = this.readString(question.header);
1728
+ const prompt = this.readString(question.question);
1729
+ if (!id || !header || !prompt) {
1730
+ continue;
1731
+ }
1732
+ questions.push({
1733
+ id,
1734
+ header,
1735
+ question: prompt,
1736
+ ...(options.length > 0 ? { options } : {}),
1737
+ ...(typeof question.isOther === "boolean" ? { isOther: question.isOther } : {}),
1738
+ ...(typeof question.isSecret === "boolean" ? { isSecret: question.isSecret } : {}),
1739
+ });
1740
+ }
1741
+ return questions;
1742
+ }
1309
1743
  extractThreadId(payload) {
1310
1744
  if (!payload || typeof payload !== "object")
1311
1745
  return undefined;