@tea-agent/loop-agent 0.44.0-next.6 → 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 (92) hide show
  1. package/CHANGELOG.md +285 -454
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/shared/git-workspace-status.js +2 -0
  4. package/dist/worker/console/chat/assistant-content.js +5 -34
  5. package/dist/worker/console/chat/chat-event-store.js +27 -10
  6. package/dist/worker/console/chat/db-connections.js +72 -8
  7. package/dist/worker/console/chat/pi-runtime/custom-tools/sql-tools.js +52 -24
  8. package/dist/worker/console/chat/pi-runtime.js +34 -0
  9. package/dist/worker/console/chat/routes.js +138 -9
  10. package/dist/worker/console/chat/session-store.js +147 -19
  11. package/dist/worker/console/chat/turn-process.js +69 -23
  12. package/dist/worker/console/chat/user-turn-outline.js +19 -0
  13. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Brn2sPzL.js → abnfDiagram-N423BO3Z-DTuku_-7.js} +1 -1
  14. package/dist/worker/console/static/assets/{arc-BIMNTG0W.js → arc-CQbCsSqb.js} +1 -1
  15. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G--WtcgR_A.js → architectureDiagram-T3A2C74G-C-xUntTR.js} +1 -1
  16. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BT_Ztog1.js → blockDiagram-VBNYF7ZC-DhNlwGAl.js} +1 -1
  17. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C5hbVr-l.js → c4Diagram-5PPSVZJV-B7j3nx8o.js} +1 -1
  18. package/dist/worker/console/static/assets/channel-5_os7t_x.js +1 -0
  19. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BKKnyR9Z.js → chunk-2GRJ4B5K-BCrcxEh2.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-BHs6biqA.js → chunk-2Q5K7J3B-CsaePDb3.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BdSFy_vP.js → chunk-5RXB4S5H--SRQc1rN.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BpJIdTKV.js → chunk-5VM5RSS4-C5BFY2Po.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-C_iWJhkT.js → chunk-6Q2QTUOP-Caso6uN7.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-BTbmx3-k.js → chunk-GF5L2VYU-AY2ZSAx2.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-D4Ee6DmP.js → chunk-JWPE2WC7-DiMqZQ_8.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B487SORR.js → chunk-KBJHAD2P-gpvvriOp.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BKYHjNrr.js → chunk-RYQCIY6F-CUdyoJhI.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DrTdoYZG.js → chunk-XXDRQBXY-Bp1xW3T4.js} +1 -1
  29. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-loz0n18W.js +1 -0
  30. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-loz0n18W.js +1 -0
  31. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CttUY_Jo.js → cose-bilkent-JH36ORCC-D-JIcOIT.js} +1 -1
  32. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B4Rm5gnR.js → cynefin-VYW2F7L2-C-NKFflx.js} +1 -1
  33. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-ByxHBq_S.js → cynefinDiagram-MW4NZA55-CSOcvOAA.js} +1 -1
  34. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-DGrfNFA0.js → dagre-VZM6K2ZE-BQSBltMy.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-KjrUPquv.js → diagram-7IWD3JNH-B0LVC45G.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-VS4mlm75.js → diagram-B4RE2ZJO-Cypg3nqy.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BLXYmFTM.js → diagram-LBJQPF4R-BBDU88XJ.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C6xoFAqH.js → diagram-Q27KOJAE-BZusLqSb.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C478dCe7.js → diagram-UB23O5K3-Cfolotcr.js} +1 -1
  40. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DHEpW5zN.js → ebnfDiagram-BXEA7PRR-CjfyUy0m.js} +1 -1
  41. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CA5GKfo9.js → erDiagram-JOGREHBK-DJdz7txE.js} +1 -1
  42. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-zysi0Fl-.js → flowDiagram-UKHOOZJN-CQqgPO6y.js} +1 -1
  43. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DGzcKA8Y.js → ganttDiagram-PKOTCBZU-BOpGfS2m.js} +1 -1
  44. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CHsuIG1w.js → gitGraphDiagram-DS77QQ5N-C6UCu0qx.js} +1 -1
  45. package/dist/worker/console/static/assets/index-CUT440A8.css +1 -0
  46. package/dist/worker/console/static/assets/index-DWKejNVJ.js +486 -0
  47. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BexZHPCp.js → infoDiagram-6WML65LV-BWeaVEUB.js} +1 -1
  48. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Gkx8q-DB.js → ishikawaDiagram-WSZJBQD7-C8gmlBbk.js} +1 -1
  49. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-XDW3qrJ7.js → journeyDiagram-NVQOT4AX-DF3bm8Bp.js} +1 -1
  50. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DJ07eEaN.js → kanban-definition-27J2QSJJ-D2fG2Vxi.js} +1 -1
  51. package/dist/worker/console/static/assets/{linear-DL-zeqOn.js → linear-BGKgMc2T.js} +1 -1
  52. package/dist/worker/console/static/assets/{mermaid.core-C0HdXtOf.js → mermaid.core-DF6mMPJ9.js} +5 -5
  53. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CqRX0lrt.js → mindmap-definition-FAOFIHXS-AMhePheC.js} +1 -1
  54. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVM9JZ2H.js → pegDiagram-VL7TDLO6-DN1Cl0W0.js} +1 -1
  55. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BA3ozdKk.js → pieDiagram-7S7Q4E2Y-Cx9ngUeN.js} +1 -1
  56. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B-0_p9_4.js → quadrantDiagram-CIZ2JOQS-BcYPTQJb.js} +1 -1
  57. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DHRzPWFc.js → railroadDiagram-AXF67PYL-BqHUZWnH.js} +1 -1
  58. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-9BEC_PcW.js → requirementDiagram-LRYGKXZP-CmqWZ0-B.js} +1 -1
  59. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DMxeW4rN.js → sankeyDiagram-W5VNT64P--4YetH1y.js} +1 -1
  60. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DixP6LIU.js → sequenceDiagram-SI44F4Z6-DGD4VMfD.js} +1 -1
  61. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B_EifJBo.js → sizeCapture-X5ZJPWSS-QVw9q7rh.js} +1 -1
  62. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BwikwWbX.js → stateDiagram-OKZ733FA-CuemV9pe.js} +1 -1
  63. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BdlGdIJw.js +1 -0
  64. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-C162l9o1.js → swimlanes-SLNWSIFB-C7oKzZKs.js} +2 -2
  65. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DjjbHbde.js +8 -0
  66. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-mpe_u1i4.js → timeline-definition-Z64GVDOM-C2Of60nT.js} +1 -1
  67. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DyUk54HX.js → vennDiagram-T6HMQDX7-DHtpQHyL.js} +1 -1
  68. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-HYyMkzfm.js → wardleyDiagram-T6FBY63Y-COi9OhSy.js} +1 -1
  69. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DRoh7skR.js → xychartDiagram-ELKLHX3M-DPHQfRrt.js} +1 -1
  70. package/dist/worker/console/static/index.html +2 -2
  71. package/dist/worker/console/static-src/operator-chat/chat-link.js +20 -0
  72. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +58 -3
  73. package/dist/worker/console/static-src/operator-chat/process-label.js +19 -6
  74. package/dist/worker/console/static-src/operator-chat/transcript-mode.js +21 -0
  75. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +2 -0
  76. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +13 -5
  77. package/dist/worker/console/static-src/operator-chat/turn-state-projection.js +166 -0
  78. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +56 -2
  79. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +202 -43
  80. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -23
  81. package/dist/worker/console/static-src/operator-chat/useChatThread.js +55 -18
  82. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +5 -2
  83. package/dist/worker/scheduler/scheduled-goal-dispatch.js +3 -2
  84. package/dist/worker/scheduler/scheduled-goal-evidence.js +6 -1
  85. package/package.json +1 -1
  86. package/dist/worker/console/static/assets/channel-DNWB4hpR.js +0 -1
  87. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-COhFgJji.js +0 -1
  88. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-COhFgJji.js +0 -1
  89. package/dist/worker/console/static/assets/index-CJh6pVLm.js +0 -482
  90. package/dist/worker/console/static/assets/index-C_kvKmFM.css +0 -1
  91. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-S2ZHMo1c.js +0 -1
  92. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BZm4ByfV.js +0 -8
@@ -0,0 +1,166 @@
1
+ export const TURN_STATE_RECONCILE_DELAYS_MS = [0, 75, 200, 500];
2
+ export const TURN_STATE_QUEUE_RETRY_DELAYS_MS = [0, 75, 200, 500, 2_000];
3
+ const MAX_TURN_STATE_QUERY_BATCH = 8;
4
+ /** Normalize server turn facts. Queued is externally still an open/running Turn. */
5
+ export function normalizeDurableTurnState(state) {
6
+ if (state === "queued" || state === "running")
7
+ return "running";
8
+ if (state === "settled" || state === "aborted" || state === "failed") {
9
+ return state;
10
+ }
11
+ return undefined;
12
+ }
13
+ /** Merge authoritative summaries without deleting older paginated Turn facts. */
14
+ export function mergeTurnStateSummaries(current, summaries) {
15
+ if (!summaries?.length)
16
+ return current;
17
+ let next;
18
+ for (const summary of summaries) {
19
+ if (typeof summary.turnId !== "string" || !summary.turnId)
20
+ continue;
21
+ const state = normalizeDurableTurnState(summary.state);
22
+ const previous = next?.[summary.turnId] ?? current[summary.turnId];
23
+ const merged = mergeDurableTurnState(previous, state);
24
+ if (!merged || previous === merged)
25
+ continue;
26
+ next ??= { ...current };
27
+ next[summary.turnId] = merged;
28
+ }
29
+ return next ?? current;
30
+ }
31
+ export function setProjectedTurnState(current, turnId, state) {
32
+ if (!turnId)
33
+ return current;
34
+ const normalized = normalizeDurableTurnState(state);
35
+ const merged = mergeDurableTurnState(current[turnId], normalized);
36
+ if (!merged || current[turnId] === merged) {
37
+ return current;
38
+ }
39
+ return { ...current, [turnId]: merged };
40
+ }
41
+ function mergeDurableTurnState(current, incoming) {
42
+ if (!incoming)
43
+ return current;
44
+ if (!current)
45
+ return incoming;
46
+ if (current === "failed" || current === "aborted")
47
+ return current;
48
+ if (current === "settled" && incoming === "running")
49
+ return current;
50
+ return incoming;
51
+ }
52
+ export function turnStateReconcileTrigger(replay, eventName) {
53
+ if (eventName !== "agent_settled")
54
+ return undefined;
55
+ return replay ? "after-replay" : "immediate";
56
+ }
57
+ export async function reconcileTurnStateWithBackoff(input) {
58
+ const delays = input.delaysMs ?? TURN_STATE_RECONCILE_DELAYS_MS;
59
+ const wait = input.wait ??
60
+ ((delayMs) => new Promise((resolve) => globalThis.setTimeout(resolve, delayMs)));
61
+ for (const delayMs of delays) {
62
+ if (delayMs > 0)
63
+ await wait(delayMs);
64
+ if (input.isCancelled?.())
65
+ return "cancelled";
66
+ let snapshot = null;
67
+ try {
68
+ snapshot = await input.read();
69
+ }
70
+ catch {
71
+ // A transport failure consumes one bounded attempt; later attempts or an
72
+ // SSE reconnect retain ownership of the pending reconciliation.
73
+ }
74
+ if (input.isCancelled?.())
75
+ return "cancelled";
76
+ if (!snapshot?.turnStates)
77
+ continue;
78
+ input.apply(snapshot.turnStates);
79
+ const state = snapshot.turnStates
80
+ .filter((summary) => summary.turnId === input.turnId)
81
+ .map((summary) => normalizeDurableTurnState(summary.state))
82
+ .find((candidate) => candidate !== undefined);
83
+ if (state && state !== "running")
84
+ return "terminal";
85
+ }
86
+ return "pending";
87
+ }
88
+ /** Serialize targeted Turn-state reads, batching replay work and retaining
89
+ * unresolved Turns until durable terminal truth arrives or the owner ends. */
90
+ export function createTurnStateReconcileQueue(input) {
91
+ const pending = new Set();
92
+ const wait = input.wait ??
93
+ ((delayMs) => new Promise((resolve) => globalThis.setTimeout(resolve, delayMs)));
94
+ const retryDelays = input.retryDelaysMs?.length
95
+ ? input.retryDelaysMs
96
+ : TURN_STATE_QUEUE_RETRY_DELAYS_MS;
97
+ let worker = null;
98
+ const run = async () => {
99
+ let retryIndex = 0;
100
+ while (pending.size > 0 && !input.isCancelled?.()) {
101
+ const delayMs = retryDelays[Math.min(retryIndex, retryDelays.length - 1)] ?? 0;
102
+ if (delayMs > 0) {
103
+ try {
104
+ await wait(delayMs);
105
+ }
106
+ catch {
107
+ if (input.isCancelled?.())
108
+ return;
109
+ }
110
+ }
111
+ if (input.isCancelled?.())
112
+ return;
113
+ const batch = [...pending].slice(0, MAX_TURN_STATE_QUERY_BATCH);
114
+ let snapshot = null;
115
+ try {
116
+ snapshot = await input.read(batch);
117
+ }
118
+ catch {
119
+ // The queue owns retry cadence; a transient read failure keeps the batch pending.
120
+ }
121
+ if (input.isCancelled?.())
122
+ return;
123
+ const summaries = snapshot?.turnStates;
124
+ if (summaries?.length)
125
+ input.apply(summaries);
126
+ let completed = 0;
127
+ for (const turnId of batch) {
128
+ const state = summaries
129
+ ?.filter((summary) => summary.turnId === turnId)
130
+ .map((summary) => normalizeDurableTurnState(summary.state))
131
+ .find((candidate) => candidate !== undefined);
132
+ if (state && state !== "running") {
133
+ pending.delete(turnId);
134
+ completed += 1;
135
+ continue;
136
+ }
137
+ // Rotate unresolved ids so batches larger than the server query cap cannot starve.
138
+ pending.delete(turnId);
139
+ pending.add(turnId);
140
+ }
141
+ retryIndex = completed > 0 ? 0 : retryIndex + 1;
142
+ }
143
+ };
144
+ const start = () => {
145
+ if (worker)
146
+ return worker;
147
+ if (pending.size === 0 || input.isCancelled?.())
148
+ return Promise.resolve();
149
+ worker = run().finally(() => {
150
+ worker = null;
151
+ if (pending.size > 0 && !input.isCancelled?.())
152
+ void start();
153
+ });
154
+ return worker;
155
+ };
156
+ return {
157
+ enqueue: (turnId, options) => {
158
+ if (!turnId || input.isCancelled?.())
159
+ return;
160
+ pending.add(turnId);
161
+ if (!options?.defer)
162
+ void start();
163
+ },
164
+ start,
165
+ };
166
+ }
@@ -19,11 +19,65 @@ export const OPERATOR_CHAT_REAL_PATHS = {
19
19
  events: "/api/operator/v1/chat/sessions/:sessionId/events",
20
20
  abort: "/api/operator/v1/chat/sessions/:sessionId/turns/:turnId/abort",
21
21
  };
22
+ const MAX_STATE_TURN_IDS = 8;
23
+ const MAX_STATE_TURN_ID_CHARS = 240;
24
+ function boundedStateTurnIds(turnIds) {
25
+ const result = [];
26
+ const seen = new Set();
27
+ for (const raw of turnIds ?? []) {
28
+ const turnId = raw.trim();
29
+ if (!turnId ||
30
+ turnId.length > MAX_STATE_TURN_ID_CHARS ||
31
+ seen.has(turnId)) {
32
+ continue;
33
+ }
34
+ seen.add(turnId);
35
+ result.push(turnId);
36
+ if (result.length >= MAX_STATE_TURN_IDS)
37
+ break;
38
+ }
39
+ return result;
40
+ }
22
41
  export function operatorChatTurnsUrl(origin, sessionId) {
23
42
  return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/turns`;
24
43
  }
25
- export function operatorChatStateUrl(origin, sessionId) {
26
- return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`;
44
+ export function operatorChatStateUrl(origin, sessionId, turnIds) {
45
+ const base = `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`;
46
+ const bounded = boundedStateTurnIds(turnIds);
47
+ if (bounded.length === 0)
48
+ return base;
49
+ const query = new URLSearchParams();
50
+ for (const turnId of bounded)
51
+ query.append("turnId", turnId);
52
+ return `${base}?${query.toString()}`;
53
+ }
54
+ /** Compose session ownership with a short request timeout and explicit cleanup. */
55
+ export function createBoundedOwnerSignal(input) {
56
+ const controller = new AbortController();
57
+ const abortFromOwner = () => controller.abort(input.ownerSignal?.reason);
58
+ if (input.ownerSignal?.aborted)
59
+ abortFromOwner();
60
+ else
61
+ input.ownerSignal?.addEventListener("abort", abortFromOwner, { once: true });
62
+ const schedule = input.schedule ??
63
+ ((callback, delayMs) => globalThis.setTimeout(callback, delayMs));
64
+ const cancel = input.cancel ??
65
+ ((handle) => globalThis.clearTimeout(handle));
66
+ const timer = controller.signal.aborted
67
+ ? undefined
68
+ : schedule(() => controller.abort(new Error("Operator Chat state request timed out")), input.timeoutMs);
69
+ let disposed = false;
70
+ return {
71
+ signal: controller.signal,
72
+ dispose: () => {
73
+ if (disposed)
74
+ return;
75
+ disposed = true;
76
+ if (timer !== undefined)
77
+ cancel(timer);
78
+ input.ownerSignal?.removeEventListener("abort", abortFromOwner);
79
+ },
80
+ };
27
81
  }
28
82
  export function operatorChatEventsUrl(origin, sessionId) {
29
83
  return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`;
@@ -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,48 +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) => params.workspacesLoading ||
281
- !item.workspaceId ||
282
- visibleWorkspaceIds.has(item.workspaceId);
283
- const durable = sessions.filter((item) => belongsToVisibleWorkspace(item) &&
284
- !pendingDeletedIdsRef.current.has(item.sessionId));
285
- const optimistic = [...pendingCreatedRowsRef.current.values()].filter((item) => belongsToVisibleWorkspace(item) &&
286
- !durable.some((session) => session.sessionId === item.sessionId));
287
- // Preserve summaries for visible workspaces. ChatWorkspaceSidebar applies
288
- // the recent-item limit independently inside each workspace group.
289
- const next = stabilizeSessionList(sessionListRef.current, [...optimistic, ...durable]);
290
- sessionListRef.current = next;
291
- setSessionList(next);
292
- if (body.workspaceErrors?.length) {
293
- setError(`部分工作区的会话加载失败(${body.workspaceErrors.length} 个)`);
294
- }
295
- }
296
- catch (error) {
297
- if (version !== sessionListRequestVersionRef.current)
298
- return;
299
- setError(`会话列表加载失败:${error instanceof Error ? error.message : String(error)}`);
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;
300
318
  }
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;
301
397
  }, [origin, blocked, setError, visibleWorkspaceIds, params.workspacesLoading]);
398
+ const invalidateSessionListBaseline = useCallback(() => {
399
+ sessionListRequestVersionRef.current += 1;
400
+ if (sessionListRefreshRef.current) {
401
+ sessionListRefreshQueuedRef.current = true;
402
+ }
403
+ }, []);
302
404
  const deleteSession = useCallback(async (sessionId) => {
303
405
  if (pendingDeletedIdsRef.current.has(sessionId))
304
406
  return false;
@@ -308,7 +410,7 @@ export function useChatSessions(params) {
308
410
  if (!removed || removedIndex < 0)
309
411
  return false;
310
412
  // No pre-mutation snapshot may overwrite this deletion or its rollback.
311
- sessionListRequestVersionRef.current += 1;
413
+ invalidateSessionListBaseline();
312
414
  pendingDeletedIdsRef.current.add(sessionId);
313
415
  deleteRequestVersionsRef.current.set(sessionId, requestId);
314
416
  const activeSession = refs.sessionRef.current?.sessionId === sessionId
@@ -341,7 +443,7 @@ export function useChatSessions(params) {
341
443
  const restore = () => {
342
444
  if (deleteRequestVersionsRef.current.get(sessionId) !== requestId)
343
445
  return;
344
- sessionListRequestVersionRef.current += 1;
446
+ invalidateSessionListBaseline();
345
447
  pendingDeletedIdsRef.current.delete(sessionId);
346
448
  deleteRequestVersionsRef.current.delete(sessionId);
347
449
  const restored = restoreSessionAt(sessionListRef.current.filter((item) => item.sessionId !== sessionId), removed, removedIndex);
@@ -412,6 +514,7 @@ export function useChatSessions(params) {
412
514
  setError,
413
515
  sessionOwnerQuery,
414
516
  workspaceId,
517
+ invalidateSessionListBaseline,
415
518
  ]);
416
519
  const forgetLostSession = useCallback((sessionId, ownerWorkspaceId) => {
417
520
  const active = refs.sessionRef.current;
@@ -424,6 +527,7 @@ export function useChatSessions(params) {
424
527
  forgetActiveChatSession(window.localStorage, scope, sessionId);
425
528
  releaseSession(identity);
426
529
  pendingDeletedIdsRef.current.add(sessionId);
530
+ invalidateSessionListBaseline();
427
531
  const { next } = removeSessionById(sessionListRef.current, sessionId);
428
532
  sessionListRef.current = next;
429
533
  setSessionList(next);
@@ -436,7 +540,15 @@ export function useChatSessions(params) {
436
540
  setSession(null);
437
541
  }
438
542
  void refreshSessionList();
439
- }, [refs, releaseSession, resetActiveProjection, setSession, workspaceId, refreshSessionList]);
543
+ }, [
544
+ refs,
545
+ releaseSession,
546
+ resetActiveProjection,
547
+ setSession,
548
+ workspaceId,
549
+ refreshSessionList,
550
+ invalidateSessionListBaseline,
551
+ ]);
440
552
  const activateSession = useCallback(async (sessionId, ownerWorkspaceId) => {
441
553
  const selectionVersion = ++sessionSelectionVersionRef.current;
442
554
  const listedOwner = sessionListRef.current.find((item) => item.sessionId === sessionId)?.workspaceId;
@@ -503,10 +615,12 @@ export function useChatSessions(params) {
503
615
  applyActivatedSession({
504
616
  messages: hydrated.messages,
505
617
  toolCalls: hydrated.toolCalls,
618
+ turnStates: body.session.turnStates,
506
619
  conversationEvents: body.conversationEvents,
507
620
  sessionStats: body.sessionStats,
508
621
  usage: body.usage,
509
622
  turnTelemetry: body.turnTelemetry,
623
+ userTurnOutline: body.userTurnOutline,
510
624
  sddSessionIdentity: body.sddDataAlignment ?? null,
511
625
  streaming: visuallyStopped ? false : Boolean(body.session.activeTurnId),
512
626
  turnPhase: body.session.turnPhase ??
@@ -543,6 +657,32 @@ export function useChatSessions(params) {
543
657
  forgetLostSession,
544
658
  ]);
545
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]);
546
686
  useEffect(() => {
547
687
  void refreshSessionList();
548
688
  }, [refreshSessionList]);
@@ -639,10 +779,12 @@ export function useChatSessions(params) {
639
779
  applyActivatedSession({
640
780
  messages: hydrated.messages,
641
781
  toolCalls: hydrated.toolCalls,
782
+ turnStates: body.session.turnStates,
642
783
  conversationEvents: body.conversationEvents,
643
784
  sessionStats: body.sessionStats,
644
785
  usage: body.usage,
645
786
  turnTelemetry: body.turnTelemetry,
787
+ userTurnOutline: body.userTurnOutline,
646
788
  sddSessionIdentity: body.sddDataAlignment ?? null,
647
789
  streaming: visuallyStopped ? false : Boolean(body.session.activeTurnId),
648
790
  turnPhase: body.session.turnPhase ??
@@ -698,6 +840,7 @@ export function useChatSessions(params) {
698
840
  optimisticState: "creating",
699
841
  };
700
842
  pendingCreatedRowsRef.current.set(optimisticSessionId, optimisticRow);
843
+ invalidateSessionListBaseline();
701
844
  const optimisticList = insertOptimisticSession(sessionListRef.current, optimisticRow);
702
845
  sessionListRef.current = optimisticList;
703
846
  setSessionList(optimisticList);
@@ -713,6 +856,14 @@ export function useChatSessions(params) {
713
856
  creatingRef: creatingSessionRef,
714
857
  });
715
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
+ }
716
867
  return created;
717
868
  }
718
869
  catch (error) {
@@ -722,7 +873,13 @@ export function useChatSessions(params) {
722
873
  setSessionList(cleaned);
723
874
  throw error;
724
875
  }
725
- }, [origin, refreshSessionList, workspaceId, setError]);
876
+ }, [
877
+ origin,
878
+ refreshSessionList,
879
+ workspaceId,
880
+ setError,
881
+ invalidateSessionListBaseline,
882
+ ]);
726
883
  const createSession = useCallback(async (options) => {
727
884
  // Suppress concurrent create from any entry (sidebar / Landing / chip).
728
885
  if (creatingSessionRef.current)
@@ -904,11 +1061,13 @@ export function useChatSessions(params) {
904
1061
  };
905
1062
  return {
906
1063
  sessionList,
1064
+ sessionListStatus,
907
1065
  selectedSession,
908
1066
  sessionSwitching: Boolean(selectedSession &&
909
1067
  refs.sessionRef.current?.sessionId !== selectedSession.sessionId),
910
1068
  creatingSession,
911
1069
  activateSession,
1070
+ forkSessionAt,
912
1071
  refreshSessionList,
913
1072
  deleteSession,
914
1073
  createSession,