@tea-agent/loop-agent 0.44.0-next.5 → 0.44.0-next.7

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 (111) hide show
  1. package/CHANGELOG.md +299 -445
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/pi-sdk-executor.js +7 -7
  4. package/dist/infrastructure/console/operation-store.js +46 -14
  5. package/dist/shared/git-workspace-status.js +2 -0
  6. package/dist/shared/wizard-local-telemetry.js +2 -1
  7. package/dist/worker/console/chat/assistant-content.js +5 -34
  8. package/dist/worker/console/chat/chat-event-store.js +27 -10
  9. package/dist/worker/console/chat/db-connections.js +72 -8
  10. package/dist/worker/console/chat/pi-runtime/custom-tools/sql-tools.js +52 -24
  11. package/dist/worker/console/chat/pi-runtime.js +251 -18
  12. package/dist/worker/console/chat/repo-browser.js +49 -0
  13. package/dist/worker/console/chat/routes.js +215 -66
  14. package/dist/worker/console/chat/session-store.js +147 -19
  15. package/dist/worker/console/chat/turn-process.js +69 -23
  16. package/dist/worker/console/chat/user-turn-outline.js +19 -0
  17. package/dist/worker/console/console-update-runtime.js +2 -2
  18. package/dist/worker/console/operator-user-error.js +28 -1
  19. package/dist/worker/console/pi-readiness.js +4 -4
  20. package/dist/worker/console/recovery-cta.js +14 -14
  21. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BHuhGM1e.js → abnfDiagram-N423BO3Z-DTuku_-7.js} +1 -1
  22. package/dist/worker/console/static/assets/{arc-CKh5E_sr.js → arc-CQbCsSqb.js} +1 -1
  23. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DizhiBEv.js → architectureDiagram-T3A2C74G-C-xUntTR.js} +1 -1
  24. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-3Neea-Z-.js → blockDiagram-VBNYF7ZC-DhNlwGAl.js} +1 -1
  25. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BCP_Qrgi.js → c4Diagram-5PPSVZJV-B7j3nx8o.js} +1 -1
  26. package/dist/worker/console/static/assets/channel-5_os7t_x.js +1 -0
  27. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-C_wydftK.js → chunk-2GRJ4B5K-BCrcxEh2.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-jRPXZbzx.js → chunk-2Q5K7J3B-CsaePDb3.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BLQf2OYO.js → chunk-5RXB4S5H--SRQc1rN.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-0qRq64rI.js → chunk-5VM5RSS4-C5BFY2Po.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-ClMg8xmV.js → chunk-6Q2QTUOP-Caso6uN7.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Coop2FlL.js → chunk-GF5L2VYU-AY2ZSAx2.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CDdO-jPJ.js → chunk-JWPE2WC7-DiMqZQ_8.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D8Gkppjl.js → chunk-KBJHAD2P-gpvvriOp.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DA9HIySf.js → chunk-RYQCIY6F-CUdyoJhI.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DZNDRX1k.js → chunk-XXDRQBXY-Bp1xW3T4.js} +1 -1
  37. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-loz0n18W.js +1 -0
  38. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-loz0n18W.js +1 -0
  39. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CZ1zGyjq.js → cose-bilkent-JH36ORCC-D-JIcOIT.js} +1 -1
  40. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B8u1_FtM.js → cynefin-VYW2F7L2-C-NKFflx.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-CPBB6lfo.js → cynefinDiagram-MW4NZA55-CSOcvOAA.js} +1 -1
  42. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CG3Eov3E.js → dagre-VZM6K2ZE-BQSBltMy.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-ycI-dOjh.js → diagram-7IWD3JNH-B0LVC45G.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Bx_GyQ7Y.js → diagram-B4RE2ZJO-Cypg3nqy.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-m0G6mP4d.js → diagram-LBJQPF4R-BBDU88XJ.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DTDJkqcJ.js → diagram-Q27KOJAE-BZusLqSb.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BCfFrG-p.js → diagram-UB23O5K3-Cfolotcr.js} +1 -1
  48. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BIkTNPpp.js → ebnfDiagram-BXEA7PRR-CjfyUy0m.js} +1 -1
  49. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DTcMWrd2.js → erDiagram-JOGREHBK-DJdz7txE.js} +1 -1
  50. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqzDaNQ7.js → flowDiagram-UKHOOZJN-CQqgPO6y.js} +1 -1
  51. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BSl_vGFe.js → ganttDiagram-PKOTCBZU-BOpGfS2m.js} +1 -1
  52. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DCA5HXM6.js → gitGraphDiagram-DS77QQ5N-C6UCu0qx.js} +1 -1
  53. package/dist/worker/console/static/assets/index-CUT440A8.css +1 -0
  54. package/dist/worker/console/static/assets/index-DWKejNVJ.js +486 -0
  55. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DvqT18ge.js → infoDiagram-6WML65LV-BWeaVEUB.js} +1 -1
  56. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B6zGKrQ3.js → ishikawaDiagram-WSZJBQD7-C8gmlBbk.js} +1 -1
  57. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BpLQPI9C.js → journeyDiagram-NVQOT4AX-DF3bm8Bp.js} +1 -1
  58. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CzIz_-Lt.js → kanban-definition-27J2QSJJ-D2fG2Vxi.js} +1 -1
  59. package/dist/worker/console/static/assets/{linear-CMQ8Jpp-.js → linear-BGKgMc2T.js} +1 -1
  60. package/dist/worker/console/static/assets/{mermaid.core-BdAMrTf7.js → mermaid.core-DF6mMPJ9.js} +5 -5
  61. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-hSwF6sOq.js → mindmap-definition-FAOFIHXS-AMhePheC.js} +1 -1
  62. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DZdv509B.js → pegDiagram-VL7TDLO6-DN1Cl0W0.js} +1 -1
  63. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-CmD4-b0K.js → pieDiagram-7S7Q4E2Y-Cx9ngUeN.js} +1 -1
  64. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DtKxNxlS.js → quadrantDiagram-CIZ2JOQS-BcYPTQJb.js} +1 -1
  65. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-D48iIoiS.js → railroadDiagram-AXF67PYL-BqHUZWnH.js} +1 -1
  66. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-C3N0K-2p.js → requirementDiagram-LRYGKXZP-CmqWZ0-B.js} +1 -1
  67. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CACWR-Xs.js → sankeyDiagram-W5VNT64P--4YetH1y.js} +1 -1
  68. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-g_QgugvZ.js → sequenceDiagram-SI44F4Z6-DGD4VMfD.js} +1 -1
  69. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DqLoRPGK.js → sizeCapture-X5ZJPWSS-QVw9q7rh.js} +1 -1
  70. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-ES59VgMG.js → stateDiagram-OKZ733FA-CuemV9pe.js} +1 -1
  71. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BdlGdIJw.js +1 -0
  72. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-fjyLVNWo.js → swimlanes-SLNWSIFB-C7oKzZKs.js} +2 -2
  73. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DjjbHbde.js +8 -0
  74. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cd5-biKv.js → timeline-definition-Z64GVDOM-C2Of60nT.js} +1 -1
  75. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CZTu6mPf.js → vennDiagram-T6HMQDX7-DHtpQHyL.js} +1 -1
  76. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DR01jjbe.js → wardleyDiagram-T6FBY63Y-COi9OhSy.js} +1 -1
  77. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-iZcuGVGa.js → xychartDiagram-ELKLHX3M-DPHQfRrt.js} +1 -1
  78. package/dist/worker/console/static/index.html +2 -2
  79. package/dist/worker/console/static-src/app/console-types.js +9 -1
  80. package/dist/worker/console/static-src/operator-chat/chat-link.js +20 -0
  81. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +58 -3
  82. package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +98 -0
  83. package/dist/worker/console/static-src/operator-chat/file-palette-nav.js +81 -0
  84. package/dist/worker/console/static-src/operator-chat/process-label.js +19 -6
  85. package/dist/worker/console/static-src/operator-chat/transcript-mode.js +21 -0
  86. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +2 -0
  87. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +13 -5
  88. package/dist/worker/console/static-src/operator-chat/turn-state-projection.js +166 -0
  89. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +56 -2
  90. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +202 -41
  91. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -23
  92. package/dist/worker/console/static-src/operator-chat/useChatThread.js +55 -18
  93. package/dist/worker/console/static-src/operator-chat/useComposer.js +80 -6
  94. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +13 -3
  95. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +36 -17
  96. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +18 -0
  97. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  98. package/dist/worker/console/static-src/shell/console-update-reload.js +1 -1
  99. package/dist/worker/console/workspace-initialization.js +3 -3
  100. package/dist/worker/observability/read-model.js +23 -2
  101. package/dist/worker/scheduler/scheduled-goal-dispatch.js +3 -2
  102. package/dist/worker/scheduler/scheduled-goal-evidence.js +6 -1
  103. package/docs/templates/backend-test-dag.json +1 -0
  104. package/package.json +4 -4
  105. package/dist/worker/console/static/assets/channel-DxgzA1GF.js +0 -1
  106. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BZcm7fs6.js +0 -1
  107. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BZcm7fs6.js +0 -1
  108. package/dist/worker/console/static/assets/index-BAEDjTbD.css +0 -1
  109. package/dist/worker/console/static/assets/index-wKEJUaP5.js +0 -482
  110. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyCIq6jx.js +0 -1
  111. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CsKtO34q.js +0 -8
@@ -8,6 +8,47 @@ import { isLostChatSessionError } from "./runtime-snapshot-store.js";
8
8
  import { upsertCompactionMessage, } from "./compaction-message.js";
9
9
  import { describeMutationGateFailure, isMutationGateFailureCode, isStaleTokenFailure, } from "./mutation-gate.js";
10
10
  import { SessionTitleWatcher } from "./session-title-watcher.js";
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function isSessionListItem(value) {
15
+ if (!isRecord(value))
16
+ return false;
17
+ const optionalString = (field) => field === undefined || typeof field === "string";
18
+ return (typeof value.sessionId === "string" &&
19
+ value.sessionId.trim().length > 0 &&
20
+ (value.state === "active" || value.state === "archived") &&
21
+ typeof value.updatedAt === "string" &&
22
+ optionalString(value.workspaceId) &&
23
+ optionalString(value.title) &&
24
+ optionalString(value.displayTitle) &&
25
+ optionalString(value.activeTurnId) &&
26
+ (value.isBlank === undefined || typeof value.isBlank === "boolean") &&
27
+ (value.sessionMode === undefined ||
28
+ value.sessionMode === "workflow" ||
29
+ value.sessionMode === "pi") &&
30
+ (value.turnPhase === undefined ||
31
+ value.turnPhase === "idle" ||
32
+ value.turnPhase === "running" ||
33
+ value.turnPhase === "stopping"));
34
+ }
35
+ /** A 2xx response is authoritative only after its versioned payload validates. */
36
+ export function parseSessionListBaseline(value) {
37
+ if (!isRecord(value) || value.schemaVersion !== 2) {
38
+ throw new Error("会话列表响应版本无效");
39
+ }
40
+ if (!Array.isArray(value.sessions) || !value.sessions.every(isSessionListItem)) {
41
+ throw new Error("会话列表响应缺少有效的 sessions");
42
+ }
43
+ const workspaceErrors = value.workspaceErrors ?? [];
44
+ if (!Array.isArray(workspaceErrors) || !workspaceErrors.every(isRecord)) {
45
+ throw new Error("会话列表响应包含无效的 workspaceErrors");
46
+ }
47
+ return {
48
+ sessions: value.sessions,
49
+ workspaceErrors: workspaceErrors,
50
+ };
51
+ }
11
52
  /** Pure list mutations keep optimistic UI deterministic and easy to verify. */
12
53
  export function insertOptimisticSession(list, row) {
13
54
  return [row, ...list.filter((item) => item.sessionId !== row.sessionId)];
@@ -199,11 +240,19 @@ export function useChatSessions(params) {
199
240
  return owner ? `?workspaceId=${encodeURIComponent(owner)}` : "";
200
241
  }, [refs]);
201
242
  const [sessionList, setSessionList] = useState([]);
243
+ const [sessionListStatus, setSessionListStatus] = useState({
244
+ phase: "pending",
245
+ state: "idle",
246
+ error: null,
247
+ });
202
248
  const [selectedSession, setSelectedSession] = useState(null);
203
249
  const selectedSessionRef = useRef(null);
204
250
  selectedSessionRef.current = selectedSession;
205
251
  const sessionListRef = useRef([]);
206
252
  const sessionListRequestVersionRef = useRef(0);
253
+ const sessionListRefreshRef = useRef(null);
254
+ const sessionListRefreshQueuedRef = useRef(false);
255
+ const sessionListGlobalErrorRef = useRef(null);
207
256
  const pendingDeletedIdsRef = useRef(new Set());
208
257
  const pendingCreatedRowsRef = useRef(new Map());
209
258
  const sessionMutationVersionRef = useRef(0);
@@ -257,46 +306,101 @@ export function useChatSessions(params) {
257
306
  setProcessOpen,
258
307
  setAutoScroll,
259
308
  ]);
260
- const refreshSessionList = useCallback(async () => {
309
+ const refreshSessionList = useCallback(() => {
261
310
  if (blocked)
262
- return; // AC-R5-002: fail-closed, zero session requests
263
- const version = ++sessionListRequestVersionRef.current;
264
- try {
265
- // The sidebar is a multi-workspace index. Keep one unscoped summary
266
- // collection so expanding or switching a workspace never projects the
267
- // previous workspace's list as an empty result while a scoped refresh is
268
- // in flight. Session detail and mutation requests remain owner-scoped.
269
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
270
- credentials: "include",
271
- });
272
- const body = (await res.json().catch(() => ({})));
273
- if (version !== sessionListRequestVersionRef.current)
274
- return;
275
- if (!res.ok) {
276
- setError(body.error?.message ?? `会话列表加载失败(HTTP ${res.status})`);
277
- return;
278
- }
279
- const sessions = body.sessions ?? [];
280
- const belongsToVisibleWorkspace = (item) => !item.workspaceId || visibleWorkspaceIds.has(item.workspaceId);
281
- const durable = sessions.filter((item) => belongsToVisibleWorkspace(item) &&
282
- !pendingDeletedIdsRef.current.has(item.sessionId));
283
- const optimistic = [...pendingCreatedRowsRef.current.values()].filter((item) => belongsToVisibleWorkspace(item) &&
284
- !durable.some((session) => session.sessionId === item.sessionId));
285
- // Preserve summaries for visible workspaces. ChatWorkspaceSidebar applies
286
- // the recent-item limit independently inside each workspace group.
287
- const next = stabilizeSessionList(sessionListRef.current, [...optimistic, ...durable]);
288
- sessionListRef.current = next;
289
- setSessionList(next);
290
- if (body.workspaceErrors?.length) {
291
- setError(`部分工作区的会话加载失败(${body.workspaceErrors.length} 个)`);
292
- }
311
+ return Promise.resolve(); // AC-R5-002: fail-closed, zero requests
312
+ if (sessionListRefreshRef.current) {
313
+ // The newest refresh may carry a different visible-workspace projection.
314
+ // Do not briefly commit the older scope before the queued baseline runs.
315
+ sessionListRequestVersionRef.current += 1;
316
+ sessionListRefreshQueuedRef.current = true;
317
+ return sessionListRefreshRef.current;
293
318
  }
294
- catch (error) {
295
- if (version !== sessionListRequestVersionRef.current)
296
- return;
297
- setError(`会话列表加载失败:${error instanceof Error ? error.message : String(error)}`);
319
+ const request = (async () => {
320
+ do {
321
+ sessionListRefreshQueuedRef.current = false;
322
+ const version = ++sessionListRequestVersionRef.current;
323
+ setSessionListStatus((current) => ({
324
+ ...current,
325
+ state: "loading",
326
+ error: null,
327
+ }));
328
+ try {
329
+ // The sidebar is one unscoped multi-workspace index. Session detail
330
+ // and mutations remain owner-scoped.
331
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
332
+ credentials: "include",
333
+ });
334
+ let rawBody;
335
+ try {
336
+ rawBody = await res.json();
337
+ }
338
+ catch {
339
+ throw new Error("会话列表响应不是有效 JSON");
340
+ }
341
+ if (version !== sessionListRequestVersionRef.current) {
342
+ sessionListRefreshQueuedRef.current = true;
343
+ continue;
344
+ }
345
+ if (!res.ok) {
346
+ const message = isRecord(rawBody) &&
347
+ isRecord(rawBody.error) &&
348
+ typeof rawBody.error.message === "string"
349
+ ? rawBody.error.message
350
+ : `会话列表加载失败(HTTP ${res.status})`;
351
+ throw new Error(message);
352
+ }
353
+ const baseline = parseSessionListBaseline(rawBody);
354
+ if (baseline.workspaceErrors.length > 0) {
355
+ throw new Error(`部分工作区的会话加载失败(${baseline.workspaceErrors.length} 个)`);
356
+ }
357
+ const belongsToVisibleWorkspace = (item) => params.workspacesLoading ||
358
+ !item.workspaceId ||
359
+ visibleWorkspaceIds.has(item.workspaceId);
360
+ const durable = baseline.sessions.filter((item) => belongsToVisibleWorkspace(item) &&
361
+ !pendingDeletedIdsRef.current.has(item.sessionId));
362
+ const optimistic = [...pendingCreatedRowsRef.current.values()].filter((item) => belongsToVisibleWorkspace(item) &&
363
+ !durable.some((session) => session.sessionId === item.sessionId));
364
+ const next = stabilizeSessionList(sessionListRef.current, [...optimistic, ...durable]);
365
+ sessionListRef.current = next;
366
+ setSessionList(next);
367
+ setSessionListStatus({ phase: "ready", state: "idle", error: null });
368
+ const previousListError = sessionListGlobalErrorRef.current;
369
+ if (previousListError) {
370
+ setError((current) => current === previousListError ? null : current);
371
+ sessionListGlobalErrorRef.current = null;
372
+ }
373
+ }
374
+ catch (error) {
375
+ if (version !== sessionListRequestVersionRef.current) {
376
+ sessionListRefreshQueuedRef.current = true;
377
+ continue;
378
+ }
379
+ const message = `会话列表加载失败:${error instanceof Error ? error.message : String(error)}`;
380
+ setSessionListStatus((current) => ({
381
+ ...current,
382
+ state: "error",
383
+ error: message,
384
+ }));
385
+ sessionListGlobalErrorRef.current = message;
386
+ setError(message);
387
+ }
388
+ } while (sessionListRefreshQueuedRef.current && !blocked);
389
+ })();
390
+ sessionListRefreshRef.current = request;
391
+ void request.finally(() => {
392
+ if (sessionListRefreshRef.current === request) {
393
+ sessionListRefreshRef.current = null;
394
+ }
395
+ });
396
+ return request;
397
+ }, [origin, blocked, setError, visibleWorkspaceIds, params.workspacesLoading]);
398
+ const invalidateSessionListBaseline = useCallback(() => {
399
+ sessionListRequestVersionRef.current += 1;
400
+ if (sessionListRefreshRef.current) {
401
+ sessionListRefreshQueuedRef.current = true;
298
402
  }
299
- }, [origin, blocked, setError, visibleWorkspaceIds]);
403
+ }, []);
300
404
  const deleteSession = useCallback(async (sessionId) => {
301
405
  if (pendingDeletedIdsRef.current.has(sessionId))
302
406
  return false;
@@ -306,7 +410,7 @@ export function useChatSessions(params) {
306
410
  if (!removed || removedIndex < 0)
307
411
  return false;
308
412
  // No pre-mutation snapshot may overwrite this deletion or its rollback.
309
- sessionListRequestVersionRef.current += 1;
413
+ invalidateSessionListBaseline();
310
414
  pendingDeletedIdsRef.current.add(sessionId);
311
415
  deleteRequestVersionsRef.current.set(sessionId, requestId);
312
416
  const activeSession = refs.sessionRef.current?.sessionId === sessionId
@@ -339,7 +443,7 @@ export function useChatSessions(params) {
339
443
  const restore = () => {
340
444
  if (deleteRequestVersionsRef.current.get(sessionId) !== requestId)
341
445
  return;
342
- sessionListRequestVersionRef.current += 1;
446
+ invalidateSessionListBaseline();
343
447
  pendingDeletedIdsRef.current.delete(sessionId);
344
448
  deleteRequestVersionsRef.current.delete(sessionId);
345
449
  const restored = restoreSessionAt(sessionListRef.current.filter((item) => item.sessionId !== sessionId), removed, removedIndex);
@@ -410,6 +514,7 @@ export function useChatSessions(params) {
410
514
  setError,
411
515
  sessionOwnerQuery,
412
516
  workspaceId,
517
+ invalidateSessionListBaseline,
413
518
  ]);
414
519
  const forgetLostSession = useCallback((sessionId, ownerWorkspaceId) => {
415
520
  const active = refs.sessionRef.current;
@@ -422,6 +527,7 @@ export function useChatSessions(params) {
422
527
  forgetActiveChatSession(window.localStorage, scope, sessionId);
423
528
  releaseSession(identity);
424
529
  pendingDeletedIdsRef.current.add(sessionId);
530
+ invalidateSessionListBaseline();
425
531
  const { next } = removeSessionById(sessionListRef.current, sessionId);
426
532
  sessionListRef.current = next;
427
533
  setSessionList(next);
@@ -434,7 +540,15 @@ export function useChatSessions(params) {
434
540
  setSession(null);
435
541
  }
436
542
  void refreshSessionList();
437
- }, [refs, releaseSession, resetActiveProjection, setSession, workspaceId, refreshSessionList]);
543
+ }, [
544
+ refs,
545
+ releaseSession,
546
+ resetActiveProjection,
547
+ setSession,
548
+ workspaceId,
549
+ refreshSessionList,
550
+ invalidateSessionListBaseline,
551
+ ]);
438
552
  const activateSession = useCallback(async (sessionId, ownerWorkspaceId) => {
439
553
  const selectionVersion = ++sessionSelectionVersionRef.current;
440
554
  const listedOwner = sessionListRef.current.find((item) => item.sessionId === sessionId)?.workspaceId;
@@ -501,10 +615,12 @@ export function useChatSessions(params) {
501
615
  applyActivatedSession({
502
616
  messages: hydrated.messages,
503
617
  toolCalls: hydrated.toolCalls,
618
+ turnStates: body.session.turnStates,
504
619
  conversationEvents: body.conversationEvents,
505
620
  sessionStats: body.sessionStats,
506
621
  usage: body.usage,
507
622
  turnTelemetry: body.turnTelemetry,
623
+ userTurnOutline: body.userTurnOutline,
508
624
  sddSessionIdentity: body.sddDataAlignment ?? null,
509
625
  streaming: visuallyStopped ? false : Boolean(body.session.activeTurnId),
510
626
  turnPhase: body.session.turnPhase ??
@@ -541,6 +657,32 @@ export function useChatSessions(params) {
541
657
  forgetLostSession,
542
658
  ]);
543
659
  activateSessionRef.current = activateSession;
660
+ const forkSessionAt = useCallback(async (throughMessageId) => {
661
+ const current = refs.sessionRef.current;
662
+ if (!current || refs.activeTurnIdRef.current)
663
+ return false;
664
+ try {
665
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(current.sessionId)}/fork${sessionOwnerQuery(current.workspaceId)}`, {
666
+ method: "POST",
667
+ credentials: "include",
668
+ headers: {
669
+ "content-type": "application/json",
670
+ "x-loop-console-confirmation": confirmationToken(),
671
+ },
672
+ body: JSON.stringify({ throughMessageId }),
673
+ });
674
+ const body = (await res.json().catch(() => ({})));
675
+ if (!res.ok || !body.sessionId) {
676
+ throw new Error(body.error?.message ?? "创建分叉会话失败");
677
+ }
678
+ await refreshSessionList();
679
+ return activateSession(body.sessionId, current.workspaceId);
680
+ }
681
+ catch (error) {
682
+ setError(error instanceof Error ? error.message : String(error));
683
+ return false;
684
+ }
685
+ }, [activateSession, origin, refreshSessionList, refs, sessionOwnerQuery, setError]);
544
686
  useEffect(() => {
545
687
  void refreshSessionList();
546
688
  }, [refreshSessionList]);
@@ -637,10 +779,12 @@ export function useChatSessions(params) {
637
779
  applyActivatedSession({
638
780
  messages: hydrated.messages,
639
781
  toolCalls: hydrated.toolCalls,
782
+ turnStates: body.session.turnStates,
640
783
  conversationEvents: body.conversationEvents,
641
784
  sessionStats: body.sessionStats,
642
785
  usage: body.usage,
643
786
  turnTelemetry: body.turnTelemetry,
787
+ userTurnOutline: body.userTurnOutline,
644
788
  sddSessionIdentity: body.sddDataAlignment ?? null,
645
789
  streaming: visuallyStopped ? false : Boolean(body.session.activeTurnId),
646
790
  turnPhase: body.session.turnPhase ??
@@ -696,6 +840,7 @@ export function useChatSessions(params) {
696
840
  optimisticState: "creating",
697
841
  };
698
842
  pendingCreatedRowsRef.current.set(optimisticSessionId, optimisticRow);
843
+ invalidateSessionListBaseline();
699
844
  const optimisticList = insertOptimisticSession(sessionListRef.current, optimisticRow);
700
845
  sessionListRef.current = optimisticList;
701
846
  setSessionList(optimisticList);
@@ -711,6 +856,14 @@ export function useChatSessions(params) {
711
856
  creatingRef: creatingSessionRef,
712
857
  });
713
858
  pendingCreatedRowsRef.current.delete(optimisticSessionId);
859
+ const durableRow = sessionListRef.current.find((item) => item.sessionId === created.sessionId);
860
+ if (durableRow) {
861
+ pendingCreatedRowsRef.current.set(created.sessionId, durableRow);
862
+ invalidateSessionListBaseline();
863
+ void refreshSessionList().finally(() => {
864
+ pendingCreatedRowsRef.current.delete(created.sessionId);
865
+ });
866
+ }
714
867
  return created;
715
868
  }
716
869
  catch (error) {
@@ -720,7 +873,13 @@ export function useChatSessions(params) {
720
873
  setSessionList(cleaned);
721
874
  throw error;
722
875
  }
723
- }, [origin, refreshSessionList, workspaceId, setError]);
876
+ }, [
877
+ origin,
878
+ refreshSessionList,
879
+ workspaceId,
880
+ setError,
881
+ invalidateSessionListBaseline,
882
+ ]);
724
883
  const createSession = useCallback(async (options) => {
725
884
  // Suppress concurrent create from any entry (sidebar / Landing / chip).
726
885
  if (creatingSessionRef.current)
@@ -902,11 +1061,13 @@ export function useChatSessions(params) {
902
1061
  };
903
1062
  return {
904
1063
  sessionList,
1064
+ sessionListStatus,
905
1065
  selectedSession,
906
1066
  sessionSwitching: Boolean(selectedSession &&
907
1067
  refs.sessionRef.current?.sessionId !== selectedSession.sessionId),
908
1068
  creatingSession,
909
1069
  activateSession,
1070
+ forkSessionAt,
910
1071
  refreshSessionList,
911
1072
  deleteSession,
912
1073
  createSession,
@@ -3,8 +3,50 @@ import { formatOperatorUserError } from "../../operator-user-error.js";
3
3
  import { inspectChatSseContinuity, readChatStreamReadyCursor, selectChatSseRepairBlocks, selectLaterChatEventId, } from "./chat-stream-continuity.js";
4
4
  import { confirmationToken, requestId, sleep } from "./format.js";
5
5
  import { classifyCreateTurnNetworkFailure, classifyCreateTurnResponse, createPendingSubmission, decideSubmissionRoute, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, keepAmbiguousAfterRetry, reconcileDeferredSubmissionFromState, reconcilePendingSubmissionFromState, resolveAmbiguousFromState, } from "./turn-submission.js";
6
- import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, turnPhaseFromOwnership, visualTurnPhaseFromOwnership, } from "./turn-stream-controller.js";
6
+ import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, createBoundedOwnerSignal, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, turnPhaseFromOwnership, visualTurnPhaseFromOwnership, } from "./turn-stream-controller.js";
7
7
  import { appendPendingSubmission, removePendingSubmission } from "./pending-user-message.js";
8
+ import { createTurnStateReconcileQueue, turnStateReconcileTrigger, } from "./turn-state-projection.js";
9
+ const STATE_REQUEST_TIMEOUT_MS = 2_000;
10
+ async function fetchReconciledSessionState(origin, sessionId, options) {
11
+ const bounded = createBoundedOwnerSignal({
12
+ ownerSignal: options?.ownerSignal,
13
+ timeoutMs: STATE_REQUEST_TIMEOUT_MS,
14
+ });
15
+ try {
16
+ const res = await fetch(operatorChatStateUrl(origin, sessionId, options?.turnIds), {
17
+ credentials: "include",
18
+ signal: bounded.signal,
19
+ });
20
+ const body = (await res.json().catch(() => ({})));
21
+ if (!res.ok)
22
+ return null;
23
+ return body.state ?? null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ finally {
29
+ bounded.dispose();
30
+ }
31
+ }
32
+ function readChatSseTurnId(block) {
33
+ const data = block
34
+ .split("\n")
35
+ .filter((line) => line.startsWith("data: "))
36
+ .map((line) => line.slice(6))
37
+ .join("");
38
+ if (!data)
39
+ return undefined;
40
+ try {
41
+ const parsed = JSON.parse(data);
42
+ return typeof parsed.turnId === "string" && parsed.turnId
43
+ ? parsed.turnId
44
+ : undefined;
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
8
50
  /** Project ChatRefs into the executable ownership bag used by the controller. */
9
51
  function ownershipBagFromRefs(refs) {
10
52
  return {
@@ -106,6 +148,17 @@ export function useChatStream(params) {
106
148
  * smooth (M4.1/M4.3). */
107
149
  const openEventsStream = useCallback(async (sessionId, generation, signal) => {
108
150
  const gapReplayBuffer = [];
151
+ const turnStateReconciles = createTurnStateReconcileQueue({
152
+ read: (turnIds) => fetchReconciledSessionState(origin, sessionId, {
153
+ turnIds,
154
+ ownerSignal: signal,
155
+ }),
156
+ apply: (summaries) => thread.reconcileTurnStates([...summaries]),
157
+ wait: (delayMs) => sleep(delayMs, signal),
158
+ isCancelled: () => signal.aborted ||
159
+ refs.sessionRef.current?.sessionId !== sessionId ||
160
+ refs.sessionGenerationRef.current !== generation,
161
+ });
109
162
  while (!signal.aborted) {
110
163
  const state = refs.eventsStreamStateRef.current;
111
164
  if (!state ||
@@ -145,6 +198,16 @@ export function useChatStream(params) {
145
198
  const block = blocks[blockIndex];
146
199
  if (block === undefined)
147
200
  continue;
201
+ const eventName = sseEventName(block);
202
+ const reconcileTrigger = turnStateReconcileTrigger(replay, eventName);
203
+ if (reconcileTrigger) {
204
+ const settledTurnId = readChatSseTurnId(block);
205
+ if (settledTurnId) {
206
+ turnStateReconciles.enqueue(settledTurnId, {
207
+ defer: reconcileTrigger === "after-replay",
208
+ });
209
+ }
210
+ }
148
211
  if (!replay) {
149
212
  const continuity = inspectChatSseContinuity(block, liveContinuityCursor);
150
213
  if (continuity.action === "reconnect") {
@@ -156,7 +219,7 @@ export function useChatStream(params) {
156
219
  break readLoop;
157
220
  }
158
221
  }
159
- if (sseEventName(block) === "stream_ready") {
222
+ if (eventName === "stream_ready") {
160
223
  const current = refs.eventsStreamStateRef.current;
161
224
  if (current?.sessionId === sessionId &&
162
225
  current.generation === generation)
@@ -179,7 +242,7 @@ export function useChatStream(params) {
179
242
  if (!replay)
180
243
  liveContinuityCursor = selectLaterChatEventId(liveContinuityCursor, eventId);
181
244
  }
182
- if (sseEventName(block) === "stream_ready" &&
245
+ if (eventName === "stream_ready" &&
183
246
  gapReplayBuffer.length > 0) {
184
247
  const repairedBlocks = selectChatSseRepairBlocks(gapReplayBuffer.splice(0), liveContinuityCursor);
185
248
  for (const repairedBlock of repairedBlocks) {
@@ -193,6 +256,9 @@ export function useChatStream(params) {
193
256
  refs.lastEventIdRef.current = selectLaterChatEventId(refs.lastEventIdRef.current, repairedEventId);
194
257
  }
195
258
  }
259
+ if (eventName === "stream_ready") {
260
+ void turnStateReconciles.start();
261
+ }
196
262
  }
197
263
  }
198
264
  if (replayRepairNeeded) {
@@ -213,7 +279,7 @@ export function useChatStream(params) {
213
279
  await sleep(2_000, signal);
214
280
  }
215
281
  }
216
- }, [origin, refs, applySseBlock]);
282
+ }, [origin, refs, applySseBlock, thread.reconcileTurnStates]);
217
283
  const startEventsStream = useCallback((sessionId, generation) => {
218
284
  const existing = refs.eventsStreamStateRef.current;
219
285
  if (existing?.sessionId === sessionId &&
@@ -249,20 +315,7 @@ export function useChatStream(params) {
249
315
  }
250
316
  throw new Error("事件连接未就绪,请检查 Console 连接后重试");
251
317
  }, [refs, startEventsStream]);
252
- const fetchSessionState = useCallback(async (sessionId) => {
253
- try {
254
- const res = await fetch(operatorChatStateUrl(origin, sessionId), {
255
- credentials: "include",
256
- });
257
- const body = (await res.json().catch(() => ({})));
258
- if (!res.ok)
259
- return null;
260
- return body.state ?? null;
261
- }
262
- catch {
263
- return null;
264
- }
265
- }, [origin]);
318
+ const fetchSessionState = useCallback((sessionId) => fetchReconciledSessionState(origin, sessionId), [origin]);
266
319
  const postCreateTurn = useCallback(async (input) => {
267
320
  try {
268
321
  // Resolve against the configured Console origin and fail closed if a
@@ -1042,20 +1095,20 @@ export function useChatStream(params) {
1042
1095
  const sessionId = session.sessionId;
1043
1096
  const sessionIdentity = { ...session };
1044
1097
  const generation = refs.sessionGenerationRef.current;
1098
+ const stateReconcileAbort = new AbortController();
1045
1099
  let cancelled = false;
1046
1100
  let syncInFlight = null;
1047
1101
  const runSync = async () => {
1048
1102
  try {
1049
- const res = await fetch(operatorChatStateUrl(origin, sessionId), {
1050
- credentials: "include",
1103
+ const state = await fetchReconciledSessionState(origin, sessionId, {
1104
+ ownerSignal: stateReconcileAbort.signal,
1051
1105
  });
1052
- const body = (await res.json().catch(() => ({})));
1053
1106
  if (cancelled ||
1054
- !res.ok ||
1107
+ !state ||
1055
1108
  refs.sessionRef.current?.sessionId !== sessionId ||
1056
1109
  refs.sessionGenerationRef.current !== generation)
1057
1110
  return;
1058
- const state = body.state;
1111
+ thread.reconcileTurnStates(state?.turnStates);
1059
1112
  const pending = refs.pendingSubmissionRef.current;
1060
1113
  // Pending ownership reconcile. Controller owns bind/release; same
1061
1114
  // clientRequestId only. Re-enter after acceptedTurnId so terminal
@@ -1200,6 +1253,7 @@ export function useChatStream(params) {
1200
1253
  void sync().finally(scheduleStopRetry);
1201
1254
  return () => {
1202
1255
  cancelled = true;
1256
+ stateReconcileAbort.abort();
1203
1257
  window.clearInterval(interval);
1204
1258
  if (stopRetryTimer !== undefined)
1205
1259
  window.clearTimeout(stopRetryTimer);
@@ -1216,6 +1270,7 @@ export function useChatStream(params) {
1216
1270
  markIdleIfCurrent,
1217
1271
  releaseSubmitOwnership,
1218
1272
  postAbortForTurn,
1273
+ thread.reconcileTurnStates,
1219
1274
  ]);
1220
1275
  // Exactly one owner starts/aborts GET /events. Switching sessions or
1221
1276
  // recovering a cursor aborts the prior stream before the next one is made.