@jait/gateway 0.1.519 → 0.1.521

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.
Files changed (89) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +19 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/providers/remote-cli-provider.d.ts.map +1 -1
  5. package/dist/providers/remote-cli-provider.js +44 -5
  6. package/dist/providers/remote-cli-provider.js.map +1 -1
  7. package/dist/routes/chat.d.ts.map +1 -1
  8. package/dist/routes/chat.js +181 -56
  9. package/dist/routes/chat.js.map +1 -1
  10. package/dist/routes/terminals.d.ts.map +1 -1
  11. package/dist/routes/terminals.js +23 -2
  12. package/dist/routes/terminals.js.map +1 -1
  13. package/dist/services/primary-link.d.ts +2 -0
  14. package/dist/services/primary-link.d.ts.map +1 -1
  15. package/dist/services/primary-link.js +81 -0
  16. package/dist/services/primary-link.js.map +1 -1
  17. package/dist/surfaces/index.d.ts +1 -0
  18. package/dist/surfaces/index.d.ts.map +1 -1
  19. package/dist/surfaces/index.js +1 -0
  20. package/dist/surfaces/index.js.map +1 -1
  21. package/dist/surfaces/remote-terminal.d.ts +44 -0
  22. package/dist/surfaces/remote-terminal.d.ts.map +1 -0
  23. package/dist/surfaces/remote-terminal.js +157 -0
  24. package/dist/surfaces/remote-terminal.js.map +1 -0
  25. package/dist/tools/agent-loop.d.ts +2 -2
  26. package/dist/tools/agent-loop.d.ts.map +1 -1
  27. package/dist/tools/agent-loop.js +6 -4
  28. package/dist/tools/agent-loop.js.map +1 -1
  29. package/dist/ws.d.ts +16 -0
  30. package/dist/ws.d.ts.map +1 -1
  31. package/dist/ws.js +89 -0
  32. package/dist/ws.js.map +1 -1
  33. package/package.json +1 -1
  34. package/web-dist/assets/{_basePickBy-4GXZ6Ixc.js → _basePickBy-D97xdOAU.js} +1 -1
  35. package/web-dist/assets/{_baseUniq-C8rcHDOr.js → _baseUniq-WW7JAj1l.js} +1 -1
  36. package/web-dist/assets/{arc-Ighm_O0d.js → arc-3mhc1c6Z.js} +1 -1
  37. package/web-dist/assets/{architectureDiagram-2XIMDMQ5-BoQ0ku_A.js → architectureDiagram-2XIMDMQ5-BNi--qrb.js} +1 -1
  38. package/web-dist/assets/{blockDiagram-WCTKOSBZ-D6l3Qis1.js → blockDiagram-WCTKOSBZ-GRQWMiTX.js} +1 -1
  39. package/web-dist/assets/{c4Diagram-IC4MRINW-Cnjd1IUe.js → c4Diagram-IC4MRINW-C42ZAGzO.js} +1 -1
  40. package/web-dist/assets/channel-C8_OwGkq.js +1 -0
  41. package/web-dist/assets/{chunk-4BX2VUAB-PS_u7IOQ.js → chunk-4BX2VUAB-DBagJPoH.js} +1 -1
  42. package/web-dist/assets/{chunk-55IACEB6-C6pB5keO.js → chunk-55IACEB6-Bb2d5l7K.js} +1 -1
  43. package/web-dist/assets/{chunk-FMBD7UC4-CN3x1VQ0.js → chunk-FMBD7UC4-CS4028UO.js} +1 -1
  44. package/web-dist/assets/{chunk-JSJVCQXG-KHgB7WcM.js → chunk-JSJVCQXG-pEPkRBug.js} +1 -1
  45. package/web-dist/assets/{chunk-KX2RTZJC-hrFhSS46.js → chunk-KX2RTZJC-DPZPReEB.js} +1 -1
  46. package/web-dist/assets/{chunk-NQ4KR5QH-DxnqGGgs.js → chunk-NQ4KR5QH-CxkV1_9p.js} +1 -1
  47. package/web-dist/assets/{chunk-QZHKN3VN-G6IBm1-B.js → chunk-QZHKN3VN-CwRRfkgR.js} +1 -1
  48. package/web-dist/assets/{chunk-WL4C6EOR-CCbc-Xur.js → chunk-WL4C6EOR-fFqRBlWe.js} +1 -1
  49. package/web-dist/assets/classDiagram-VBA2DB6C-C78vWo8s.js +1 -0
  50. package/web-dist/assets/classDiagram-v2-RAHNMMFH-C78vWo8s.js +1 -0
  51. package/web-dist/assets/clone-Cyv8PlV4.js +1 -0
  52. package/web-dist/assets/{cose-bilkent-S5V4N54A-CQB1kRU6.js → cose-bilkent-S5V4N54A-DN3v9QU8.js} +1 -1
  53. package/web-dist/assets/{dagre-KLK3FWXG-DrmxWwMD.js → dagre-KLK3FWXG-MbLMo82j.js} +1 -1
  54. package/web-dist/assets/{diagram-E7M64L7V-2voGx62g.js → diagram-E7M64L7V--9bssKST.js} +1 -1
  55. package/web-dist/assets/{diagram-IFDJBPK2-Bva2BWRv.js → diagram-IFDJBPK2-6cDHg5fI.js} +1 -1
  56. package/web-dist/assets/{diagram-P4PSJMXO-Brc9UEH9.js → diagram-P4PSJMXO-CnppAHOs.js} +1 -1
  57. package/web-dist/assets/{erDiagram-INFDFZHY-BEWpAybG.js → erDiagram-INFDFZHY-TLp6z50h.js} +1 -1
  58. package/web-dist/assets/{flowDiagram-PKNHOUZH-Hz_dbESe.js → flowDiagram-PKNHOUZH-DWGC6P2V.js} +1 -1
  59. package/web-dist/assets/{ganttDiagram-A5KZAMGK-Cm9_LZlr.js → ganttDiagram-A5KZAMGK-xRTztCVN.js} +1 -1
  60. package/web-dist/assets/{gitGraphDiagram-K3NZZRJ6-BzAOIXCz.js → gitGraphDiagram-K3NZZRJ6-C71gFMh3.js} +1 -1
  61. package/web-dist/assets/{graph-CJOpfjgA.js → graph-llADkI2L.js} +1 -1
  62. package/web-dist/assets/{index-DRBaHTGk.css → index-BZrUCLxt.css} +1 -1
  63. package/web-dist/assets/{index-C61DE3ai.js → index-CdADtvED.js} +1 -1
  64. package/web-dist/assets/{index-Zx1B8JUl.js → index-DF1xQK9v.js} +374 -374
  65. package/web-dist/assets/{index-Ct5S701c.js → index-qskLMmip.js} +1 -1
  66. package/web-dist/assets/{infoDiagram-LFFYTUFH-Bt7Hd4ca.js → infoDiagram-LFFYTUFH-4auIkcGV.js} +1 -1
  67. package/web-dist/assets/{ishikawaDiagram-PHBUUO56-kRYwr5j6.js → ishikawaDiagram-PHBUUO56-QNl96mQj.js} +1 -1
  68. package/web-dist/assets/{journeyDiagram-4ABVD52K-D62phOj4.js → journeyDiagram-4ABVD52K-CRkJoa9h.js} +1 -1
  69. package/web-dist/assets/{kanban-definition-K7BYSVSG-CDphYUb1.js → kanban-definition-K7BYSVSG-Bfoj75nt.js} +1 -1
  70. package/web-dist/assets/{layout-BVxY5QRl.js → layout-ZxnBXXOL.js} +1 -1
  71. package/web-dist/assets/{linear-CVsEUrat.js → linear-BkTIMqg7.js} +1 -1
  72. package/web-dist/assets/{mindmap-definition-YRQLILUH-BMKbEMM2.js → mindmap-definition-YRQLILUH-2y6NaKAS.js} +1 -1
  73. package/web-dist/assets/{pieDiagram-SKSYHLDU-CeASSviv.js → pieDiagram-SKSYHLDU-C7mUVJbX.js} +1 -1
  74. package/web-dist/assets/{quadrantDiagram-337W2JSQ-DyyBTffn.js → quadrantDiagram-337W2JSQ-CdZLSE27.js} +1 -1
  75. package/web-dist/assets/{requirementDiagram-Z7DCOOCP-CtNWH2d-.js → requirementDiagram-Z7DCOOCP-D1ICfZ5y.js} +1 -1
  76. package/web-dist/assets/{sankeyDiagram-WA2Y5GQK-BKwJNuto.js → sankeyDiagram-WA2Y5GQK-B5xjy5zy.js} +1 -1
  77. package/web-dist/assets/{sequenceDiagram-2WXFIKYE-CKaypO_N.js → sequenceDiagram-2WXFIKYE-rUHHa6Ef.js} +1 -1
  78. package/web-dist/assets/{stateDiagram-RAJIS63D-DPg4aXWw.js → stateDiagram-RAJIS63D-B0snaZqf.js} +1 -1
  79. package/web-dist/assets/stateDiagram-v2-FVOUBMTO-BYlXc09O.js +1 -0
  80. package/web-dist/assets/{timeline-definition-YZTLITO2-C-Uv37-w.js → timeline-definition-YZTLITO2-YTe8muSH.js} +1 -1
  81. package/web-dist/assets/{treemap-KZPCXAKY-CTpBRroI.js → treemap-KZPCXAKY-BaJ0_K6k.js} +1 -1
  82. package/web-dist/assets/{vennDiagram-LZ73GAT5-BpbnxQAX.js → vennDiagram-LZ73GAT5-Clg7FJSd.js} +1 -1
  83. package/web-dist/assets/{xychartDiagram-JWTSCODW-B4A0fzY8.js → xychartDiagram-JWTSCODW-CN5F0XWs.js} +1 -1
  84. package/web-dist/index.html +2 -2
  85. package/web-dist/assets/channel-Bb2O48C6.js +0 -1
  86. package/web-dist/assets/classDiagram-VBA2DB6C-ByArUuoT.js +0 -1
  87. package/web-dist/assets/classDiagram-v2-RAHNMMFH-ByArUuoT.js +0 -1
  88. package/web-dist/assets/clone-R6yqfY1r.js +0 -1
  89. package/web-dist/assets/stateDiagram-v2-FVOUBMTO-BJoc4OMi.js +0 -1
@@ -6,7 +6,7 @@ import { RemoteCliProvider } from "../providers/remote-cli-provider.js";
6
6
  import { resolveProjectRoot } from "../tools/core/get-fs.js";
7
7
  import { existsSync } from "node:fs";
8
8
  import { messages as messagesTable } from "../db/schema.js";
9
- import { eq } from "drizzle-orm";
9
+ import { eq, sql } from "drizzle-orm";
10
10
  import { uuidv7 } from "../db/uuidv7.js";
11
11
  import { requireAuth } from "../security/http-auth.js";
12
12
  import { signAuthToken } from "../security/http-auth.js";
@@ -541,6 +541,34 @@ function accumulateToolStart(sessionId, callId, tool, args, parentCallId) {
541
541
  acc.segments.push({ type: "toolGroup", callIds: [callId] });
542
542
  }
543
543
  }
544
+ function accumulateToolApproval(sessionId, requestId, tool, args) {
545
+ const callId = `approval-${requestId}`;
546
+ const acc = getOrCreateAccumulator(sessionId);
547
+ const existing = acc.toolCalls.find(t => t.callId === callId);
548
+ if (!existing) {
549
+ acc.toolCalls.push({
550
+ callId,
551
+ tool,
552
+ args,
553
+ ok: true,
554
+ message: "Awaiting approval",
555
+ status: "pending",
556
+ approvalRequestId: requestId,
557
+ approvalState: "pending",
558
+ startedAt: Date.now(),
559
+ });
560
+ }
561
+ const last = acc.segments[acc.segments.length - 1];
562
+ if (last?.type === "toolGroup") {
563
+ if (!last.callIds.includes(callId)) {
564
+ acc.segments[acc.segments.length - 1] = { type: "toolGroup", callIds: [...last.callIds, callId] };
565
+ }
566
+ }
567
+ else {
568
+ acc.segments.push({ type: "toolGroup", callIds: [callId] });
569
+ }
570
+ return callId;
571
+ }
544
572
  /** Record streaming output for a tool call */
545
573
  function accumulateToolOutput(sessionId, callId, content) {
546
574
  const acc = sessionStreamingState.get(sessionId);
@@ -623,9 +651,11 @@ function mapPersistedToolCallsForUI(toolCalls) {
623
651
  parentCallId: tc.parentCallId,
624
652
  tool: tc.tool,
625
653
  args: (typeof tc.args === "object" && tc.args !== null ? tc.args : {}),
626
- status: tc.ok ? "success" : "error",
654
+ status: tc.status ?? (tc.ok ? "success" : "error"),
627
655
  ok: tc.ok,
628
656
  message: tc.message,
657
+ approvalRequestId: tc.approvalRequestId,
658
+ approvalState: tc.approvalState,
629
659
  output: tc.output,
630
660
  data: tc.data,
631
661
  startedAt: tc.startedAt,
@@ -716,6 +746,65 @@ function windowMessages(messages, limit, before) {
716
746
  hasMore: start > 0,
717
747
  };
718
748
  }
749
+ function rowToUIMsg(sessionId, row, visibleIndex) {
750
+ const msg = {
751
+ id: `${sessionId}-${visibleIndex}`,
752
+ role: row.role,
753
+ content: typeof row.content === "string" ? row.content : String(row.content ?? ""),
754
+ };
755
+ if (row.toolCalls) {
756
+ try {
757
+ msg.toolCalls = JSON.parse(row.toolCalls);
758
+ }
759
+ catch { /* ignore */ }
760
+ }
761
+ if (row.segments) {
762
+ try {
763
+ msg.segments = JSON.parse(row.segments);
764
+ }
765
+ catch { /* ignore */ }
766
+ }
767
+ if (row.thinking) {
768
+ msg.thinking = row.thinking;
769
+ }
770
+ if (row.contextFlow) {
771
+ try {
772
+ const parsed = JSON.parse(row.contextFlow);
773
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.rounds)) {
774
+ msg.contextFlow = parsed;
775
+ }
776
+ }
777
+ catch { /* ignore */ }
778
+ }
779
+ return msg;
780
+ }
781
+ function persistedMessageWindow(db, sessionId, limit, before) {
782
+ const totalRow = db
783
+ .select({ count: sql `count(*)` })
784
+ .from(messagesTable)
785
+ .where(eq(messagesTable.sessionId, sessionId))
786
+ .get();
787
+ const total = Number(totalRow?.count ?? 0);
788
+ const end = typeof before === "number" && Number.isFinite(before) && before >= 0 && before < total
789
+ ? Math.floor(before)
790
+ : total;
791
+ const start = Math.max(end - limit, 0);
792
+ const rows = start < end
793
+ ? db
794
+ .select()
795
+ .from(messagesTable)
796
+ .where(eq(messagesTable.sessionId, sessionId))
797
+ .orderBy(messagesTable.createdAt, messagesTable.id)
798
+ .limit(end - start)
799
+ .offset(start)
800
+ .all()
801
+ : [];
802
+ return {
803
+ messages: rows.map((row, index) => rowToUIMsg(sessionId, row, start + index)),
804
+ total,
805
+ hasMore: start > 0,
806
+ };
807
+ }
719
808
  function sleep(ms) {
720
809
  return new Promise((resolve) => setTimeout(resolve, ms));
721
810
  }
@@ -1015,7 +1104,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
1015
1104
  .select()
1016
1105
  .from(messagesTable)
1017
1106
  .where(eq(messagesTable.sessionId, sessionId))
1018
- .orderBy(messagesTable.createdAt)
1107
+ .orderBy(messagesTable.createdAt, messagesTable.id)
1019
1108
  .all();
1020
1109
  if (rows.length > 0) {
1021
1110
  sessionHistory.set(sessionId, [
@@ -1095,7 +1184,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
1095
1184
  .select()
1096
1185
  .from(messagesTable)
1097
1186
  .where(eq(messagesTable.sessionId, sessionId))
1098
- .orderBy(messagesTable.createdAt)
1187
+ .orderBy(messagesTable.createdAt, messagesTable.id)
1099
1188
  .all();
1100
1189
  const lastAssistant = [...rows].reverse().find((row) => row.role === "assistant");
1101
1190
  if (!lastAssistant)
@@ -1253,6 +1342,9 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
1253
1342
  "Content-Type": "text/event-stream",
1254
1343
  "Cache-Control": "no-cache",
1255
1344
  Connection: "keep-alive",
1345
+ // Prevent reverse proxies (nginx etc.) from buffering the SSE stream,
1346
+ // which would otherwise swallow chunks and let the connection time out.
1347
+ "X-Accel-Buffering": "no",
1256
1348
  "Access-Control-Allow-Origin": reqOrigin,
1257
1349
  "Access-Control-Allow-Credentials": "true",
1258
1350
  });
@@ -1374,6 +1466,21 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
1374
1466
  }
1375
1467
  }
1376
1468
  };
1469
+ // ── SSE keepalive heartbeat ──
1470
+ // While the agentic loop runs a long tool or waits for a slow LLM response,
1471
+ // no SSE data events are emitted and the connection can go idle for tens of
1472
+ // seconds. Browsers and reverse proxies then close the socket, surfacing as
1473
+ // "fetch failed" on the frontend and dropping the in-progress assistant
1474
+ // message. Emit an SSE comment (": keepalive\n\n") every 15s — comments are
1475
+ // ignored by the EventSource/reader parser but keep the TCP connection alive.
1476
+ const keepalive = setInterval(() => {
1477
+ if (clientDisconnected) {
1478
+ clearInterval(keepalive);
1479
+ return;
1480
+ }
1481
+ safeWrite(`: keepalive\n\n`);
1482
+ }, 15_000);
1483
+ reply.raw.on("close", () => { clearInterval(keepalive); });
1377
1484
  const matchedSkillIds = new Set(matchSkills(content, promptCtx.skills ?? []));
1378
1485
  const matchedSkills = (promptCtx.skills ?? []).filter((skill) => matchedSkillIds.has(skill.id));
1379
1486
  const turnSkillToolCall = buildSyntheticSkillToolCall(matchedSkills);
@@ -1447,6 +1554,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
1447
1554
  cliProvider = providerRegistry.get(requestProvider) ?? null;
1448
1555
  }
1449
1556
  if (!cliProvider) {
1557
+ clearInterval(keepalive);
1450
1558
  safeWrite(`data: ${JSON.stringify({ type: "error", message: `Unknown provider: ${requestProvider}` })}\n\n`);
1451
1559
  reply.raw.end();
1452
1560
  return;
@@ -1695,9 +1803,13 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
1695
1803
  }
1696
1804
  break;
1697
1805
  }
1698
- case "tool.approval-required":
1699
- safeWrite(`data: ${JSON.stringify({ type: "approval_required", tool: event.tool, args: event.args, requestId: event.requestId })}\n\n`);
1806
+ case "tool.approval-required": {
1807
+ const callId = accumulateToolApproval(sessionId, event.requestId, event.tool, event.args);
1808
+ const approvalEvent = { type: "approval_required", call_id: callId, request_id: event.requestId, tool: event.tool, args: event.args };
1809
+ safeWrite(`data: ${JSON.stringify(approvalEvent)}\n\n`);
1810
+ emitToSubscribers(sessionId, approvalEvent);
1700
1811
  break;
1812
+ }
1701
1813
  case "message":
1702
1814
  if (event.role === "assistant" && event.content) {
1703
1815
  flushToolGroup();
@@ -2104,6 +2216,8 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2104
2216
  const tcJson = partialToolCalls.length > 0 ? JSON.stringify(partialToolCalls) : undefined;
2105
2217
  persistMessage(sessionId, "assistant", fullContent || "", tcJson, resultSegmentsJson, contextFlowJson);
2106
2218
  }
2219
+ // Stop the SSE keepalive heartbeat now that the turn is finishing.
2220
+ clearInterval(keepalive);
2107
2221
  activeStreams.delete(sessionId);
2108
2222
  sessionAbortControllers.delete(sessionId);
2109
2223
  sessionSteeringControllers.delete(sessionId);
@@ -2161,6 +2275,30 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2161
2275
  }
2162
2276
  void drainQueuedChatMessages(sessionId);
2163
2277
  });
2278
+ app.post("/api/sessions/:sessionId/approve", async (request, reply) => {
2279
+ const authUser = await requireAuth(request, reply, config.jwtSecret);
2280
+ if (!authUser)
2281
+ return;
2282
+ const { sessionId } = request.params;
2283
+ const body = request.body;
2284
+ const requestId = typeof body["requestId"] === "string" ? body["requestId"] : "";
2285
+ const approved = body["approved"] !== false;
2286
+ if (!requestId) {
2287
+ return reply.status(400).send({ error: "VALIDATION_ERROR", details: "requestId is required" });
2288
+ }
2289
+ if (sessionService) {
2290
+ const session = sessionService.getById(sessionId, authUser.id);
2291
+ if (!session) {
2292
+ return reply.status(404).send({ error: "NOT_FOUND", details: "Session not found" });
2293
+ }
2294
+ }
2295
+ const activeCliSession = activeCliSessions.get(sessionId);
2296
+ if (!activeCliSession) {
2297
+ return reply.status(409).send({ error: "CONFLICT", details: "No active provider session for approval" });
2298
+ }
2299
+ await activeCliSession.provider.respondToApproval(activeCliSession.providerSessionId, requestId, approved);
2300
+ return reply.status(200).send({ ok: true });
2301
+ });
2164
2302
  // Cancel an active stream for a session
2165
2303
  app.post("/api/sessions/:sessionId/cancel", async (request, reply) => {
2166
2304
  const authUser = await requireAuth(request, reply, config.jwtSecret);
@@ -2257,7 +2395,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2257
2395
  .select()
2258
2396
  .from(messagesTable)
2259
2397
  .where(eq(messagesTable.sessionId, sessionId))
2260
- .orderBy(messagesTable.createdAt)
2398
+ .orderBy(messagesTable.createdAt, messagesTable.id)
2261
2399
  .all();
2262
2400
  const rowsToDelete = rows.slice(Math.max(0, target.historyIndex - 1));
2263
2401
  for (const row of rowsToDelete) {
@@ -2299,13 +2437,18 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2299
2437
  : typeof query?.before === "string"
2300
2438
  ? Number.parseInt(query.before, 10)
2301
2439
  : undefined;
2302
- hydrateSession(sessionId);
2303
- const history = sessionHistory.get(sessionId) ?? [];
2304
- const visible = buildVisibleHistoryMessages(sessionId, history);
2305
- const windowed = windowMessages(visible, limit, Number.isFinite(before) ? before : undefined);
2440
+ const streaming = activeStreams.has(sessionId);
2441
+ const windowed = db && !streaming
2442
+ ? persistedMessageWindow(db, sessionId, limit, Number.isFinite(before) ? before : undefined)
2443
+ : (() => {
2444
+ hydrateSession(sessionId);
2445
+ const history = sessionHistory.get(sessionId) ?? [];
2446
+ const visible = buildVisibleHistoryMessages(sessionId, history, { includePendingAssistantToolCalls: streaming });
2447
+ return windowMessages(visible, limit, Number.isFinite(before) ? before : undefined);
2448
+ })();
2306
2449
  return {
2307
2450
  sessionId,
2308
- streaming: activeStreams.has(sessionId),
2451
+ streaming,
2309
2452
  total: windowed.total,
2310
2453
  hasMore: windowed.hasMore,
2311
2454
  limit,
@@ -2332,59 +2475,26 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2332
2475
  "Content-Type": "text/event-stream",
2333
2476
  "Cache-Control": "no-cache",
2334
2477
  Connection: "keep-alive",
2478
+ "X-Accel-Buffering": "no",
2335
2479
  "Access-Control-Allow-Origin": reqOrigin,
2336
2480
  "Access-Control-Allow-Credentials": "true",
2337
2481
  });
2338
- hydrateSession(sessionId);
2339
- const history = sessionHistory.get(sessionId) ?? [];
2340
2482
  const isStreaming = activeStreams.has(sessionId);
2483
+ // Only the DB-windowed idle path can skip hydrating. Sessions without a DB
2484
+ // (in-memory history) still need their completed messages loaded from the
2485
+ // sessionHistory map, and streaming sessions need partial content too.
2486
+ let history = [];
2487
+ if (!db || isStreaming) {
2488
+ hydrateSession(sessionId);
2489
+ history = sessionHistory.get(sessionId) ?? [];
2490
+ }
2341
2491
  // Build snapshot. While streaming, prefer in-memory history so partial assistant
2342
2492
  // content is visible immediately (DB persistence may lag until stream completion).
2343
2493
  let snapshotMessages;
2344
2494
  let total = 0;
2345
2495
  let hasMore = false;
2346
2496
  if (db && !isStreaming) {
2347
- const rows = db
2348
- .select()
2349
- .from(messagesTable)
2350
- .where(eq(messagesTable.sessionId, sessionId))
2351
- .orderBy(messagesTable.createdAt)
2352
- .all();
2353
- const allMessages = rows
2354
- .filter((r) => r.role === "user" || r.role === "assistant")
2355
- .map((r, i) => {
2356
- const msg = {
2357
- id: `${sessionId}-${i}`,
2358
- role: r.role,
2359
- content: typeof r.content === "string" ? r.content : String(r.content ?? ""),
2360
- };
2361
- if (r.toolCalls) {
2362
- try {
2363
- msg.toolCalls = JSON.parse(r.toolCalls);
2364
- }
2365
- catch { /* ignore */ }
2366
- }
2367
- if (r.segments) {
2368
- try {
2369
- msg.segments = JSON.parse(r.segments);
2370
- }
2371
- catch { /* ignore */ }
2372
- }
2373
- if (r.thinking) {
2374
- msg.thinking = r.thinking;
2375
- }
2376
- if (r.contextFlow) {
2377
- try {
2378
- const parsed = JSON.parse(r.contextFlow);
2379
- if (parsed && typeof parsed === "object" && Array.isArray(parsed.rounds)) {
2380
- msg.contextFlow = parsed;
2381
- }
2382
- }
2383
- catch { /* ignore */ }
2384
- }
2385
- return msg;
2386
- });
2387
- const windowed = windowMessages(allMessages, limit);
2497
+ const windowed = persistedMessageWindow(db, sessionId, limit);
2388
2498
  snapshotMessages = windowed.messages;
2389
2499
  total = windowed.total;
2390
2500
  hasMore = windowed.hasMore;
@@ -2415,10 +2525,13 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2415
2525
  const snapshotSeq = sessionStreamSeq.get(sessionId) ?? 0;
2416
2526
  let closed = false;
2417
2527
  let unsubscribe = () => { };
2528
+ let keepalive = null;
2418
2529
  const closeStream = () => {
2419
2530
  if (closed)
2420
2531
  return;
2421
2532
  closed = true;
2533
+ if (keepalive)
2534
+ clearInterval(keepalive);
2422
2535
  unsubscribe();
2423
2536
  try {
2424
2537
  reply.raw.end();
@@ -2428,6 +2541,18 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2428
2541
  reply.raw.on("close", () => {
2429
2542
  closeStream();
2430
2543
  });
2544
+ // Keep the SSE connection alive during idle periods (long tool runs / slow
2545
+ // LLM responses) so browsers/proxies don't drop it with "fetch failed".
2546
+ keepalive = setInterval(() => {
2547
+ if (closed)
2548
+ return;
2549
+ try {
2550
+ reply.raw.write(`: keepalive\n\n`);
2551
+ }
2552
+ catch {
2553
+ closeStream();
2554
+ }
2555
+ }, 15_000);
2431
2556
  unsubscribe = subscribe(sessionId, snapshotSeq, (event) => {
2432
2557
  if (closed)
2433
2558
  return;
@@ -2491,7 +2616,7 @@ export function registerChatRoutes(app, config, depsOrDb, sessionServiceArg) {
2491
2616
  .select()
2492
2617
  .from(messagesTable)
2493
2618
  .where(eq(messagesTable.sessionId, sessionId))
2494
- .orderBy(messagesTable.createdAt)
2619
+ .orderBy(messagesTable.createdAt, messagesTable.id)
2495
2620
  .all();
2496
2621
  const lastAssistant = [...rows].reverse().find((r) => r.role === "assistant");
2497
2622
  if (lastAssistant) {