@mono-agent/agent-runtime 0.14.0 → 0.15.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.
@@ -1048,6 +1048,27 @@ function usageFromTokenUsage(tokenUsage) {
1048
1048
  };
1049
1049
  }
1050
1050
 
1051
+ function contextUsageFromTokenUsage(tokenUsage) {
1052
+ const last = tokenUsage?.last;
1053
+ const total = Number(last?.totalTokens);
1054
+ if (!last || !Number.isFinite(total) || total <= 0) return null;
1055
+ const input = Number(last.inputTokens) || 0;
1056
+ const cachedInput = Number(last.cachedInputTokens) || 0;
1057
+ const output = Number(last.outputTokens) || 0;
1058
+ const reasoning = Number(last.reasoningOutputTokens) || 0;
1059
+ const contextWindow = Number(tokenUsage?.modelContextWindow) || 0;
1060
+ return {
1061
+ tokens: {
1062
+ input: Math.max(0, input - cachedInput),
1063
+ cachedInput,
1064
+ output,
1065
+ reasoning,
1066
+ total,
1067
+ },
1068
+ ...(contextWindow > 0 ? { contextWindow } : {}),
1069
+ };
1070
+ }
1071
+
1051
1072
  const noopNotificationHandler = () => {};
1052
1073
 
1053
1074
  async function closeCodexClient(client) {
@@ -1076,6 +1097,7 @@ const codexLiveness = createSessionLiveness(codexSessions);
1076
1097
  export async function generateCodexAppResponse(systemPrompt, options = {}) {
1077
1098
  const start = Date.now();
1078
1099
  const resolved = options.model;
1100
+ const requestedReference = resolved?.reference || `codex:${resolved?.model || ""}`;
1079
1101
  // Resolve every credential-bearing value before the app-server client is
1080
1102
  // constructed. The same set protects transport errors and provider events,
1081
1103
  // including MCP servers whose custom env/header names are not recognizable
@@ -1105,8 +1127,13 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1105
1127
  const events = [];
1106
1128
  const texts = [];
1107
1129
  const agentTextByItem = new Map();
1130
+ const compactionStatuses = new Map();
1131
+ const activeCompactions = new Map();
1132
+ const nativeCompactionTurnKeys = new Set();
1133
+ const legacyCompactionTurnKeys = new Set();
1108
1134
  let threadId = null;
1109
1135
  let activeTurnId = null;
1136
+ let actualModel = resolved?.model || null;
1110
1137
  let turnCompleted = false;
1111
1138
  let errorMessage = null;
1112
1139
  let failureKind = null;
@@ -1148,6 +1175,77 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1148
1175
  options.onEvent?.(safeEvent);
1149
1176
  }
1150
1177
 
1178
+ const compactionTurnKey = (params = {}) => `${params.threadId || threadId || "thread"}:${params.turnId || activeTurnId || "turn"}`;
1179
+
1180
+ /**
1181
+ * @param {{operationId: string, status: string, turnKey?: string, reason?: string, message?: string}} event
1182
+ */
1183
+ function emitCompaction({
1184
+ operationId,
1185
+ status,
1186
+ turnKey,
1187
+ reason,
1188
+ message,
1189
+ }) {
1190
+ const previous = compactionStatuses.get(operationId);
1191
+ if (previous === status || previous === "succeeded" || previous === "failed" || previous === "skipped") return;
1192
+ compactionStatuses.set(operationId, status);
1193
+ if (status === "running") activeCompactions.set(operationId, { turnKey: turnKey || compactionTurnKey() });
1194
+ else activeCompactions.delete(operationId);
1195
+ emitEvent({
1196
+ type: "context_compaction",
1197
+ operationId,
1198
+ status,
1199
+ sdk: "codex",
1200
+ trigger: "automatic",
1201
+ timestamp: Date.now(),
1202
+ model: actualModel ? `codex:${actualModel}` : requestedReference,
1203
+ ...(reason ? { reason } : {}),
1204
+ ...(message ? { message } : {}),
1205
+ });
1206
+ }
1207
+
1208
+ function finalizeOpenCompactions(reason, message) {
1209
+ for (const [operationId, active] of [...activeCompactions]) {
1210
+ emitCompaction({
1211
+ operationId,
1212
+ status: "failed",
1213
+ turnKey: active.turnKey,
1214
+ reason,
1215
+ message,
1216
+ });
1217
+ }
1218
+ }
1219
+
1220
+ function handleContextCompactionItem(method, params) {
1221
+ const item = params.item;
1222
+ const turnKey = compactionTurnKey(params);
1223
+ nativeCompactionTurnKeys.add(turnKey);
1224
+ if (legacyCompactionTurnKeys.has(turnKey)) return;
1225
+ const operationId = `codex:${item.id}`;
1226
+ emitCompaction({
1227
+ operationId,
1228
+ status: method === "item/started" ? "running" : "succeeded",
1229
+ turnKey,
1230
+ });
1231
+ }
1232
+
1233
+ function handleLegacyCompaction(params) {
1234
+ const turnKey = compactionTurnKey(params);
1235
+ const active = [...activeCompactions].find(([, value]) => value.turnKey === turnKey);
1236
+ if (active) {
1237
+ emitCompaction({ operationId: active[0], status: "succeeded", turnKey });
1238
+ return;
1239
+ }
1240
+ if (nativeCompactionTurnKeys.has(turnKey) || legacyCompactionTurnKeys.has(turnKey)) return;
1241
+ legacyCompactionTurnKeys.add(turnKey);
1242
+ emitCompaction({
1243
+ operationId: `codex:${turnKey}:legacy`,
1244
+ status: "succeeded",
1245
+ turnKey,
1246
+ });
1247
+ }
1248
+
1151
1249
  function handleAgentText(text) {
1152
1250
  const safeText = redactCodexDiagnostic(text, sensitiveValues);
1153
1251
  pushUniqueText(texts, safeText);
@@ -1229,6 +1327,13 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1229
1327
  errorMessage = safeDiagnostic(params.turn?.error?.message || params.turn?.error || "Codex turn failed");
1230
1328
  failureKind = "provider_unavailable";
1231
1329
  }
1330
+ if (activeCompactions.size > 0) {
1331
+ const cancelled = params.turn?.status === "cancelled" || params.turn?.status === "interrupted";
1332
+ finalizeOpenCompactions(
1333
+ cancelled ? "cancelled" : "incomplete",
1334
+ cancelled ? "Compaction was interrupted." : "Compaction ended without a completion event.",
1335
+ );
1336
+ }
1232
1337
  const safeTurn = params.turn?.error === undefined
1233
1338
  ? params.turn
1234
1339
  : { ...params.turn, error: safeResponseError(params.turn.error) };
@@ -1238,6 +1343,27 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1238
1343
  }
1239
1344
  if (method === "thread/tokenUsage/updated") {
1240
1345
  usage = usageFromTokenUsage(params.tokenUsage);
1346
+ const contextUsage = contextUsageFromTokenUsage(params.tokenUsage);
1347
+ if (contextUsage) {
1348
+ emitEvent({
1349
+ type: "context_usage",
1350
+ sdk: "codex",
1351
+ model: actualModel ? `codex:${actualModel}` : requestedReference,
1352
+ timestamp: Date.now(),
1353
+ ...(typeof params.turnId === "string" && params.turnId.length > 0
1354
+ ? { measurementId: params.turnId }
1355
+ : {}),
1356
+ ...contextUsage,
1357
+ });
1358
+ }
1359
+ return;
1360
+ }
1361
+ if (method === "model/rerouted") {
1362
+ if (typeof params.toModel === "string" && params.toModel.trim().length > 0) actualModel = params.toModel;
1363
+ return;
1364
+ }
1365
+ if (method === "thread/compacted") {
1366
+ handleLegacyCompaction(params);
1241
1367
  return;
1242
1368
  }
1243
1369
  if (method === "item/agentMessage/delta") {
@@ -1258,6 +1384,10 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1258
1384
  return;
1259
1385
  }
1260
1386
  if (method === "item/started" || method === "item/completed") {
1387
+ if (params.item?.type === "contextCompaction") {
1388
+ handleContextCompactionItem(method, params);
1389
+ return;
1390
+ }
1261
1391
  const raw = mapThreadItem(method, params.item);
1262
1392
  if (params.item?.type === "agentMessage") {
1263
1393
  const text = params.item.text || agentTextByItem.get(params.item.id) || "";
@@ -1363,60 +1493,79 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1363
1493
 
1364
1494
  async function steerLiveInput() {
1365
1495
  if (!options.liveInput) return;
1366
- for await (const message of options.liveInput) {
1367
- if (turnCompleted) break;
1368
- if (!threadId || !activeTurnId || !turnReadyResolved) {
1369
- await Promise.race([
1370
- turnReady,
1371
- turnDone,
1372
- client.closed.then((err) => { throw err; }),
1496
+ const iterator = options.liveInput[Symbol.asyncIterator]();
1497
+ try {
1498
+ while (!turnCompleted) {
1499
+ const next = await Promise.race([
1500
+ iterator.next(),
1501
+ turnDone.then(() => ({ done: true, value: undefined })),
1373
1502
  ]);
1374
- if (turnCompleted || !turnReadyResolved) break;
1375
- }
1376
- const input = userTextInput(formatLiveInputGuidance(message.body, options.prompts));
1377
- try {
1378
- const response = await client.request("turn/steer", {
1379
- threadId,
1380
- expectedTurnId: activeTurnId,
1381
- input,
1382
- });
1383
- activeTurnId = response?.turnId || activeTurnId;
1384
- } catch (err) {
1385
- const providerError = err?.responseError;
1386
- if (isNoActiveTurnToSteer(providerError || err)) {
1503
+ if (next.done || turnCompleted) break;
1504
+ const message = next.value;
1505
+ if (!threadId || !activeTurnId || !turnReadyResolved) {
1387
1506
  await Promise.race([
1388
1507
  turnReady,
1389
1508
  turnDone,
1390
- client.closed.then((closedErr) => { throw closedErr; }),
1509
+ client.closed.then((err) => { throw err; }),
1391
1510
  ]);
1392
- if (turnCompleted) break;
1393
- try {
1394
- const response = await client.request("turn/steer", {
1395
- threadId,
1396
- expectedTurnId: activeTurnId,
1397
- input,
1398
- });
1399
- activeTurnId = response?.turnId || activeTurnId;
1400
- continue;
1401
- } catch (retryErr) {
1402
- const retryProviderError = retryErr?.responseError
1403
- ? safeResponseError(retryErr.responseError)
1404
- : null;
1405
- emitEvent({
1406
- type: "runtime_warning",
1407
- warning_kind: isActiveTurnNotSteerable(retryProviderError) ? "active_turn_not_steerable" : "live_input_rejected",
1408
- message: safeDiagnostic(codexErrorMessage(retryProviderError || retryErr)),
1409
- });
1410
- continue;
1511
+ if (turnCompleted || !turnReadyResolved) break;
1512
+ }
1513
+ const input = userTextInput(formatLiveInputGuidance(message.body, options.prompts));
1514
+ try {
1515
+ const response = await client.request("turn/steer", {
1516
+ threadId,
1517
+ expectedTurnId: activeTurnId,
1518
+ input,
1519
+ });
1520
+ activeTurnId = response?.turnId || activeTurnId;
1521
+ message.acknowledge?.();
1522
+ } catch (err) {
1523
+ const providerError = err?.responseError;
1524
+ if (isNoActiveTurnToSteer(providerError || err)) {
1525
+ await Promise.race([
1526
+ turnReady,
1527
+ turnDone,
1528
+ client.closed.then((closedErr) => { throw closedErr; }),
1529
+ ]);
1530
+ if (turnCompleted) break;
1531
+ try {
1532
+ const response = await client.request("turn/steer", {
1533
+ threadId,
1534
+ expectedTurnId: activeTurnId,
1535
+ input,
1536
+ });
1537
+ activeTurnId = response?.turnId || activeTurnId;
1538
+ message.acknowledge?.();
1539
+ continue;
1540
+ } catch (retryErr) {
1541
+ message.reject?.(retryErr);
1542
+ const retryProviderError = retryErr?.responseError
1543
+ ? safeResponseError(retryErr.responseError)
1544
+ : null;
1545
+ emitEvent({
1546
+ type: "runtime_warning",
1547
+ warning_kind: isActiveTurnNotSteerable(retryProviderError) ? "active_turn_not_steerable" : "live_input_rejected",
1548
+ message: safeDiagnostic(codexErrorMessage(retryProviderError || retryErr)),
1549
+ });
1550
+ // Preserve FIFO fallback: once one message is rejected, later
1551
+ // entries must not overtake it inside this provider attempt.
1552
+ break;
1553
+ }
1411
1554
  }
1555
+ message.reject?.(err);
1556
+ emitEvent({
1557
+ type: "runtime_warning",
1558
+ warning_kind: isActiveTurnNotSteerable(providerError) ? "active_turn_not_steerable" : "live_input_rejected",
1559
+ message: safeDiagnostic(codexErrorMessage(
1560
+ providerError ? safeResponseError(providerError) : err,
1561
+ )),
1562
+ });
1563
+ break;
1412
1564
  }
1413
- emitEvent({
1414
- type: "runtime_warning",
1415
- warning_kind: isActiveTurnNotSteerable(providerError) ? "active_turn_not_steerable" : "live_input_rejected",
1416
- message: safeDiagnostic(codexErrorMessage(
1417
- providerError ? safeResponseError(providerError) : err,
1418
- )),
1419
- });
1565
+ }
1566
+ } finally {
1567
+ if (typeof iterator.return === "function") {
1568
+ try { void Promise.resolve(iterator.return()).catch(() => {}); } catch { /* best-effort */ }
1420
1569
  }
1421
1570
  }
1422
1571
  }
@@ -1656,7 +1805,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1656
1805
  abortRaceCleanup();
1657
1806
  }
1658
1807
  turnCompleted = true;
1659
- await Promise.race([steerTask, Promise.resolve()]);
1808
+ await steerTask;
1660
1809
 
1661
1810
  const text = texts[texts.length - 1] || "";
1662
1811
  let codexErrorCode = prematureClose ? "codex_app_server_closed" : null;
@@ -1683,7 +1832,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1683
1832
  });
1684
1833
  }
1685
1834
  const hadPartialProgress = events.length > 0 || texts.length > 0;
1686
- const reference = `codex:${resolved.model}`;
1835
+ const reference = requestedReference;
1687
1836
  const inputTokens = usage?.input_tokens ?? usage?.inputTokens ?? 0;
1688
1837
  const outputTokens = usage?.output_tokens ?? usage?.outputTokens ?? 0;
1689
1838
  const cachedTokens = usage?.cache_read_tokens ?? usage?.cachedInputTokens ?? 0;
@@ -1774,6 +1923,13 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1774
1923
  }),
1775
1924
  };
1776
1925
  } finally {
1926
+ if (activeCompactions.size > 0) {
1927
+ const cancelled = !!options.abortSignal?.aborted;
1928
+ finalizeOpenCompactions(
1929
+ cancelled ? "cancelled" : "incomplete",
1930
+ cancelled ? "Compaction was interrupted." : "Compaction ended without a completion event.",
1931
+ );
1932
+ }
1777
1933
  options.abortSignal?.removeEventListener?.("abort", abortHandler);
1778
1934
  if (resumeEntry) {
1779
1935
  resumeEntry.busy = false;
@@ -1784,12 +1940,6 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1784
1940
  }
1785
1941
  }
1786
1942
 
1787
- export const codexAppBackend = {
1788
- kind: "codex-app",
1789
- capabilities: CODEX_APP_CAPABILITIES,
1790
- execute: generateCodexAppResponse,
1791
- };
1792
-
1793
1943
  // CLI bridge for sdk='codex' agents that opt into execution_mode='cli'. The
1794
1944
  // codex `app-server` is more capable than `codex exec` (better event
1795
1945
  // streaming, MCP support), so this is the default CLI path for Codex.
@@ -319,6 +319,57 @@ function usageFromInfo(info) {
319
319
  };
320
320
  }
321
321
 
322
+ async function opencodeContextWindows(client, directoryParams) {
323
+ try {
324
+ if (typeof client?.provider?.list !== "function") return new Map();
325
+ const listed = unwrap(await client.provider.list(directoryParams));
326
+ const providers = Array.isArray(listed?.all) ? listed.all : [];
327
+ const windows = new Map();
328
+ for (const provider of providers) {
329
+ const models = provider?.models && typeof provider.models === "object"
330
+ ? Object.values(provider.models)
331
+ : [];
332
+ for (const model of models) {
333
+ const providerID = model?.providerID || provider?.id;
334
+ const modelID = model?.id;
335
+ const contextWindow = Number(model?.limit?.context) || 0;
336
+ if (providerID && modelID && contextWindow > 0) {
337
+ windows.set(`${providerID}:${modelID}`, contextWindow);
338
+ }
339
+ }
340
+ }
341
+ return windows;
342
+ } catch {
343
+ return new Map();
344
+ }
345
+ }
346
+
347
+ function contextUsageFromInfo(info, contextWindows, fallbackProviderID, fallbackModelID) {
348
+ if (info?.role !== "assistant" || info.error) return null;
349
+ const total = num(info?.tokens?.total);
350
+ if (total === null || total <= 0) return null;
351
+ const providerID = info.providerID || fallbackProviderID;
352
+ const modelID = info.modelID || fallbackModelID;
353
+ const input = num(info.tokens?.input) || 0;
354
+ const output = num(info.tokens?.output) || 0;
355
+ const reasoning = num(info.tokens?.reasoning) || 0;
356
+ const cachedInput = num(info.tokens?.cache?.read) || 0;
357
+ const cacheCreation = num(info.tokens?.cache?.write) || 0;
358
+ const contextWindow = contextWindows.get(`${providerID}:${modelID}`);
359
+ return {
360
+ model: `opencode:${providerID}:${modelID}`,
361
+ tokens: {
362
+ input: Math.max(0, input - cachedInput),
363
+ cachedInput,
364
+ cacheCreation,
365
+ output,
366
+ reasoning,
367
+ total,
368
+ },
369
+ ...(contextWindow === undefined ? {} : { contextWindow }),
370
+ };
371
+ }
372
+
322
373
  function aggregateAssistantInfos(infos) {
323
374
  const entries = [...infos];
324
375
  const totals = {
@@ -587,11 +638,71 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
587
638
  const textDeltaPartIds = new Set();
588
639
  const reasoningDeltaPartIds = new Set();
589
640
  const assistantInfos = new Map();
590
- const recordAssistantInfo = (info, fallbackKey) => {
641
+ const contextUsageSignatures = new Map();
642
+ const compactionStatuses = new Map();
643
+ const activeCompactions = new Map();
644
+ const seenLegacyCompactionIds = new Set();
645
+ let nativeCompactionSeen = false;
646
+ let legacyCompactionMode = false;
647
+ let contextWindows = new Map();
648
+
649
+ /**
650
+ * @param {{operationId: string, status: string, trigger?: string, timestamp?: number, reason?: string, message?: string}} event
651
+ */
652
+ const emitCompaction = ({ operationId, status, trigger = "automatic", timestamp, reason, message }) => {
653
+ const previous = compactionStatuses.get(operationId);
654
+ if (previous === status || previous === "succeeded" || previous === "failed" || previous === "skipped") return;
655
+ compactionStatuses.set(operationId, status);
656
+ if (status === "running") activeCompactions.set(operationId, { trigger });
657
+ else activeCompactions.delete(operationId);
658
+ emit({
659
+ type: "context_compaction",
660
+ operationId,
661
+ status,
662
+ sdk: "opencode",
663
+ trigger,
664
+ timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
665
+ model: reference,
666
+ ...(reason ? { reason } : {}),
667
+ ...(message ? { message } : {}),
668
+ });
669
+ };
670
+
671
+ const finalizeOpenCompactions = (reason, message) => {
672
+ for (const [operationId, active] of [...activeCompactions]) {
673
+ emitCompaction({
674
+ operationId,
675
+ status: "failed",
676
+ trigger: active.trigger,
677
+ reason,
678
+ message,
679
+ });
680
+ }
681
+ };
682
+
683
+ const recordAssistantInfo = (info, fallbackKey, { terminal = false } = {}) => {
591
684
  if (info?.role !== "assistant") return;
592
685
  const key = typeof info.id === "string" && info.id.length > 0 ? info.id : fallbackKey;
593
686
  assistantInfos.set(key, info);
594
687
  usage = aggregateAssistantInfos(assistantInfos.values()).usage;
688
+ const completed = terminal
689
+ || Number.isFinite(info?.time?.completed)
690
+ || (typeof info?.finish === "string" && info.finish.length > 0);
691
+ if (!completed) return;
692
+ const contextUsage = contextUsageFromInfo(info, contextWindows, providerID, modelID);
693
+ if (!contextUsage) return;
694
+ const signature = JSON.stringify([contextUsage.model, contextUsage.contextWindow, contextUsage.tokens]);
695
+ if (contextUsageSignatures.get(key) === signature) return;
696
+ contextUsageSignatures.set(key, signature);
697
+ emit({
698
+ type: "context_usage",
699
+ sdk: "opencode",
700
+ model: contextUsage.model,
701
+ timestamp: Number.isFinite(info?.time?.completed) ? info.time.completed : Date.now(),
702
+ measurementId: key,
703
+ ...(contextUsage.contextWindow === undefined ? {} : { contextWindow: contextUsage.contextWindow }),
704
+ tokens: contextUsage.tokens,
705
+ });
595
706
  };
596
707
 
597
708
  const abortHandler = () => {
@@ -618,6 +729,7 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
618
729
  client = opencode.client;
619
730
  server = opencode.server;
620
731
  options.abortSignal?.addEventListener?.("abort", abortHandler, { once: true });
732
+ contextWindows = await opencodeContextWindows(client, directoryParams);
621
733
 
622
734
  if (!sessionId) {
623
735
  const created = unwrap(await client.session.create(directoryParams));
@@ -758,6 +870,47 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
758
870
  recordAssistantInfo(info, "event-anonymous");
759
871
  return;
760
872
  }
873
+ case "session.next.compaction.started": {
874
+ if (props.sessionID && props.sessionID !== sessionId) return;
875
+ nativeCompactionSeen = true;
876
+ if (legacyCompactionMode) return;
877
+ const operationId = `opencode:${sessionId}:${props.messageID || event.id || "compaction"}`;
878
+ emitCompaction({
879
+ operationId,
880
+ status: "running",
881
+ trigger: props.reason === "manual" ? "manual" : "automatic",
882
+ timestamp: props.timestamp,
883
+ });
884
+ return;
885
+ }
886
+ case "session.next.compaction.ended": {
887
+ if (props.sessionID && props.sessionID !== sessionId) return;
888
+ nativeCompactionSeen = true;
889
+ if (legacyCompactionMode) return;
890
+ const operationId = `opencode:${sessionId}:${props.messageID || event.id || "compaction"}`;
891
+ emitCompaction({
892
+ operationId,
893
+ status: "succeeded",
894
+ trigger: props.reason === "manual" ? "manual" : "automatic",
895
+ timestamp: props.timestamp,
896
+ });
897
+ return;
898
+ }
899
+ case "session.compacted": {
900
+ if (props.sessionID && props.sessionID !== sessionId) return;
901
+ if (nativeCompactionSeen) return;
902
+ legacyCompactionMode = true;
903
+ const legacyId = typeof event.id === "string" && event.id.length > 0
904
+ ? event.id
905
+ : randomUUID();
906
+ if (seenLegacyCompactionIds.has(legacyId)) return;
907
+ seenLegacyCompactionIds.add(legacyId);
908
+ emitCompaction({
909
+ operationId: `opencode:${sessionId}:legacy:${legacyId}`,
910
+ status: "succeeded",
911
+ });
912
+ return;
913
+ }
761
914
  case "permission.asked":
762
915
  await respondToPermission(props);
763
916
  return;
@@ -765,10 +918,14 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
765
918
  if (props.sessionID && props.sessionID !== sessionId) return;
766
919
  errorMessage = safeOpenCodeErrorMessage(props.error, "OpenCode session error.");
767
920
  failureKind = mapErrorFailureKind(props.error);
921
+ finalizeOpenCompactions("provider_error", "Compaction was interrupted by a provider error.");
768
922
  pumpDone = true;
769
923
  return;
770
924
  case "session.idle":
771
- if (props.sessionID === sessionId) pumpDone = true;
925
+ if (props.sessionID === sessionId) {
926
+ finalizeOpenCompactions("incomplete", "Compaction ended without a completion event.");
927
+ pumpDone = true;
928
+ }
772
929
  return;
773
930
  default:
774
931
  return;
@@ -815,7 +972,7 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
815
972
  }
816
973
 
817
974
  const info = promptResult?.info || {};
818
- recordAssistantInfo(info, "prompt-final");
975
+ recordAssistantInfo(info, "prompt-final", { terminal: true });
819
976
  if (info.error && !errorMessage) {
820
977
  errorMessage = safeOpenCodeErrorMessage(info.error, "OpenCode turn failed.");
821
978
  failureKind = mapErrorFailureKind(info.error);
@@ -882,6 +1039,10 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
882
1039
  }),
883
1040
  };
884
1041
  } catch (err) {
1042
+ finalizeOpenCompactions(
1043
+ options.abortSignal?.aborted ? "cancelled" : "provider_error",
1044
+ options.abortSignal?.aborted ? "Compaction was interrupted." : "Compaction was interrupted by a provider error.",
1045
+ );
885
1046
  const partialAggregate = aggregateAssistantInfos(assistantInfos.values());
886
1047
  const partialUsage = partialAggregate.usage ?? usage;
887
1048
  const partialCost = partialAggregate.reportedCost !== null
@@ -929,6 +1090,10 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
929
1090
  }),
930
1091
  };
931
1092
  } finally {
1093
+ finalizeOpenCompactions(
1094
+ options.abortSignal?.aborted ? "cancelled" : "incomplete",
1095
+ options.abortSignal?.aborted ? "Compaction was interrupted." : "Compaction ended without a completion event.",
1096
+ );
932
1097
  options.abortSignal?.removeEventListener?.("abort", abortHandler);
933
1098
  try { await server?.close?.(); } catch { /* best effort */ }
934
1099
  }
@@ -1,13 +1,5 @@
1
1
  import { EMPTY_USAGE } from "./pi-models.js";
2
2
 
3
- export function promptTextFromMessages(messages) {
4
- if (!Array.isArray(messages) || !messages.length) return "";
5
- return messages
6
- .filter((message) => message?.role === "user")
7
- .map((message) => typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""))
8
- .join("\n");
9
- }
10
-
11
3
  function messageContent(value) {
12
4
  if (typeof value === "string") return value;
13
5
  if (Array.isArray(value)) {