@tea-agent/loop-agent 0.40.0-next.3 → 0.40.0-next.5

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 (139) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +46 -5
  4. package/dist/executors/shell-executor.js +28 -0
  5. package/dist/executors/workspace-write-snapshot.js +68 -0
  6. package/dist/infrastructure/harness/atomic-write.js +23 -0
  7. package/dist/task/config-types.js +10 -0
  8. package/dist/worker/console/chat/chat-event-store.js +62 -4
  9. package/dist/worker/console/chat/context-panel.js +7 -0
  10. package/dist/worker/console/chat/deferred-turn.js +12 -0
  11. package/dist/worker/console/chat/pi-runtime.js +264 -16
  12. package/dist/worker/console/chat/provider-error.js +98 -0
  13. package/dist/worker/console/chat/routes.js +292 -29
  14. package/dist/worker/console/chat/session-stats.js +179 -0
  15. package/dist/worker/console/chat/session-store.js +93 -63
  16. package/dist/worker/console/chat/todos.js +115 -0
  17. package/dist/worker/console/chat/tool-preview.js +42 -0
  18. package/dist/worker/console/chat/turn-process.js +20 -55
  19. package/dist/worker/console/chat/user-questions.js +266 -0
  20. package/dist/worker/console/chat/workspace-landing.js +14 -0
  21. package/dist/worker/console/native-directory-picker.js +13 -0
  22. package/dist/worker/console/routes.js +309 -1
  23. package/dist/worker/console/security.js +4 -4
  24. package/dist/worker/console/server.js +529 -186
  25. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-CGZrpj0K.js} +1 -1
  26. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-CwEyA8iq.js} +1 -1
  27. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-DLTkrEQ3.js} +1 -1
  28. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-4w12zR3t.js} +1 -1
  29. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-BppqLAjk.js} +1 -1
  30. package/dist/worker/console/static/assets/channel-TzuwQha5.js +1 -0
  31. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-DO6U26Pm.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-DC7YEdOy.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-DtMYVQBw.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C5Ed-57N.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-BUY4oIzT.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-VnWHALIV.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-COV-RQk4.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-dSnLtUiz.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-CX6FDzWh.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-D3mEgb6W.js} +1 -1
  41. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DCu69b3S.js +1 -0
  42. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DCu69b3S.js +1 -0
  43. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-QQBMUnAf.js} +1 -1
  44. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-BxSD-LVV.js} +1 -1
  45. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-DxHNiNbk.js} +1 -1
  46. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-C83bYy8E.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-BhtZu2YY.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-K0P_ntRE.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-CXYy1Nib.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-D-CtIFkU.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-BEK9-_7c.js} +1 -1
  52. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-L0EX-9wn.js} +1 -1
  53. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-9pt0fc_5.js} +1 -1
  54. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CZVeM_n1.js} +1 -1
  55. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-DqUKkAZP.js} +1 -1
  56. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-BYUuXEgp.js} +1 -1
  57. package/dist/worker/console/static/assets/index-B0jGzQj6.js +407 -0
  58. package/dist/worker/console/static/assets/index-BiYbPiTw.css +1 -0
  59. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-DNzKk7ah.js} +1 -1
  60. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-Clbyg5IP.js} +1 -1
  61. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-zDO_gZNO.js} +1 -1
  62. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-x3LpRYek.js} +1 -1
  63. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-CTpwZqKR.js} +1 -1
  64. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-D_WXK6C7.js} +5 -5
  65. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-CWK-OYP5.js} +1 -1
  66. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-B9gtUPsM.js} +1 -1
  67. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-Dj2lzceR.js} +1 -1
  68. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-Nwljut7m.js} +1 -1
  69. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-BY-KNt93.js} +1 -1
  70. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-Vx0wvpPy.js} +1 -1
  71. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B6grNXUk.js} +1 -1
  72. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-CteidBvh.js} +1 -1
  73. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-Du4W8mFK.js} +1 -1
  74. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-jyI6Yh81.js} +1 -1
  75. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyQblISl.js +1 -0
  76. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-DYWs_fRv.js} +2 -2
  77. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DiygmgYP.js +8 -0
  78. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-DO5LLy47.js} +1 -1
  79. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-CLF2p1_L.js} +1 -1
  80. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-AwTaYBxA.js} +1 -1
  81. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-DzbblQRw.js} +1 -1
  82. package/dist/worker/console/static/index.html +2 -2
  83. package/dist/worker/console/static-src/app/console-types.js +29 -4
  84. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  85. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  86. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  87. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  88. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  89. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  90. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +140 -8
  91. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  92. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  93. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  94. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  95. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +168 -72
  96. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -17
  97. package/dist/worker/console/static-src/operator-chat/useChatThread.js +184 -4
  98. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  99. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  100. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  101. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  102. package/dist/worker/console/static-src/shell/workspace-route.js +337 -0
  103. package/dist/worker/console/workspace-context.js +234 -0
  104. package/dist/worker/console/workspace-registry.js +214 -0
  105. package/dist/worker/observe/server.js +4 -0
  106. package/dist/worker/observe/static/api.js +26 -6
  107. package/dist/worker/observe/static/app.js +18 -70
  108. package/dist/worker/observe/static/index.html +1 -1
  109. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  110. package/dist/worker/observe/static/operator-chrome.css +145 -20
  111. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  112. package/dist/worker/observe/static/operator-chrome.js +330 -100
  113. package/dist/worker/observe/static/router.d.ts +46 -0
  114. package/dist/worker/observe/static/router.js +25 -1
  115. package/dist/worker/observe/static/state.d.ts +50 -0
  116. package/dist/worker/observe/static/state.js +172 -0
  117. package/dist/worker/observe/static/styles.css +20 -1
  118. package/dist/worker/observe/static/views/dag-inspector.js +4 -3
  119. package/dist/worker/observe/static/views/dag.js +2 -1
  120. package/dist/worker/observe/static/views/dashboard.js +6 -4
  121. package/dist/worker/observe/static/views/night.js +2 -2
  122. package/dist/worker/observe/static/views/pool.js +1 -0
  123. package/dist/worker/observe/static/views/run.js +3 -1
  124. package/dist/worker/observe/static/views/task.js +1 -1
  125. package/dist/workflows/dag/init-hybrid.js +19 -0
  126. package/dist/workflows/dag/interrupt-request.js +3 -3
  127. package/dist/workflows/dag/lifecycle.js +3 -3
  128. package/dist/workflows/dag/types.js +3 -1
  129. package/docs/README.md +1 -0
  130. package/docs/operations/local-development-environment.md +21 -0
  131. package/harness.json +3 -3
  132. package/package.json +3 -1
  133. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  134. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  135. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  136. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  137. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  138. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  139. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
@@ -1,5 +1,4 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { parseAssistantContent } from "../../chat/assistant-content.js";
3
2
  import { runtimeSelectionFromRecord } from "../../chat/runtime-selection.js";
4
3
  import { hydrateSessionThread } from "../../chat/turn-process.js";
5
4
  import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
@@ -28,6 +27,9 @@ export async function createSessionAndPrimeTitleWatcher(input) {
28
27
  });
29
28
  if (!res.ok) {
30
29
  const body = (await res.json().catch(() => ({})));
30
+ if (isMutationGateFailureCode(body.error?.code)) {
31
+ throw new Error(describeMutationGateFailure(body.error.code));
32
+ }
31
33
  throw new Error(body.error?.message ?? `HTTP ${res.status}`);
32
34
  }
33
35
  const body = (await res.json());
@@ -36,6 +38,11 @@ export async function createSessionAndPrimeTitleWatcher(input) {
36
38
  const durable = body.session;
37
39
  const created = {
38
40
  sessionId: durable.sessionId,
41
+ ...(durable.workspaceId || input.options?.workspaceId
42
+ ? {
43
+ workspaceId: durable.workspaceId ?? input.options?.workspaceId,
44
+ }
45
+ : {}),
39
46
  ...runtimeSelectionFromRecord(durable),
40
47
  };
41
48
  const nextList = [
@@ -56,6 +63,30 @@ export async function createSessionAndPrimeTitleWatcher(input) {
56
63
  input.setCreatingSession?.(false);
57
64
  }
58
65
  }
66
+ function mergeCompactionMessages(messages, compactions) {
67
+ const result = [...messages];
68
+ for (const item of compactions ?? []) {
69
+ const data = item.data && typeof item.data === "object" ? item.data : {};
70
+ const eventId = typeof item.eventId === "string" ? item.eventId : undefined;
71
+ const id = eventId ? `compaction-${eventId}` : `compaction-${item.at ?? Date.now()}`;
72
+ if (result.some((message) => message.id === id))
73
+ continue;
74
+ const summary = typeof data.summary === "string" ? data.summary : "历史上下文已精简,关键决策与当前任务已保留。";
75
+ result.push({
76
+ id,
77
+ role: "assistant",
78
+ kind: "compaction",
79
+ text: summary,
80
+ createdAt: Date.parse(item.at ?? "") || Date.now(),
81
+ compaction: {
82
+ ...(typeof data.tokensBefore === "number" ? { tokensBefore: data.tokensBefore } : {}),
83
+ ...(typeof data.firstKeptEntryId === "string" ? { firstKeptEntryId: data.firstKeptEntryId } : {}),
84
+ ...(eventId ? { eventId } : {}),
85
+ },
86
+ });
87
+ }
88
+ return result.sort((a, b) => a.createdAt - b.createdAt);
89
+ }
59
90
  /** Session lifecycle: list / activate / create / delete / rename-archive /
60
91
  * compact, plus browser-refresh recovery from the durable session store. */
61
92
  export function useChatSessions(params) {
@@ -63,6 +94,21 @@ export function useChatSessions(params) {
63
94
  const { applyActivatedSession, resetForNewSession, setMessages } = thread;
64
95
  const { setInput, setPendingImages } = composer;
65
96
  const { setPreviews, setActivePreviewPath } = browser;
97
+ const { workspaceId, blocked } = params;
98
+ const workspaceQuery = workspaceId
99
+ ? `?workspaceId=${encodeURIComponent(workspaceId)}`
100
+ : "";
101
+ /** AC-002 (2026-08-26): session requests follow the session's DURABLE
102
+ * owning workspace, not the global active workspace. The sidebar list
103
+ * items carry workspaceId; once a session is activated its owner is kept
104
+ * on the session record itself. Switching the Operate/Inspect active
105
+ * workspace therefore never retargets an open session's requests. */
106
+ const sessionOwnerQuery = useCallback((ownerWorkspaceId) => {
107
+ const owner = ownerWorkspaceId?.trim() ||
108
+ refs.sessionRef.current?.workspaceId?.trim() ||
109
+ "";
110
+ return owner ? `?workspaceId=${encodeURIComponent(owner)}` : "";
111
+ }, [refs]);
66
112
  const [sessionList, setSessionList] = useState([]);
67
113
  const sessionListRef = useRef([]);
68
114
  sessionListRef.current = sessionList;
@@ -76,11 +122,15 @@ export function useChatSessions(params) {
76
122
  // state. This prevents slow responses from an older selection overwriting a
77
123
  // newer user choice.
78
124
  const sessionSelectionVersionRef = useRef(0);
79
- const resetActiveProjection = useCallback(() => {
80
- // M4.5/M5.2: selection is a hard ownership boundary. Abort every request
81
- // owned by the old projection, then invalidate its async callbacks.
125
+ const resetActiveProjection = useCallback((options) => {
126
+ // M4.5/M5.2: selection is a hard ownership boundary for THIS projection.
127
+ // Multi-workspace (AC-002): keepStreams=true (workspace switch) aborts
128
+ // only projection-local requests and leaves background SSE streams of
129
+ // other sessions/workspaces running.
130
+ const keepStreams = options?.keepStreams === true;
82
131
  refs.sessionGenerationRef.current += 1;
83
- refs.abortRef.current?.abort();
132
+ if (!keepStreams)
133
+ refs.abortRef.current?.abort();
84
134
  refs.reconnectAbortRef.current?.abort();
85
135
  for (const controller of refs.sessionAbortControllersRef.current.values())
86
136
  controller.abort();
@@ -114,43 +164,36 @@ export function useChatSessions(params) {
114
164
  setAutoScroll,
115
165
  ]);
116
166
  const refreshSessionList = useCallback(async () => {
167
+ if (blocked)
168
+ return; // AC-R5-002: fail-closed, zero session requests
117
169
  try {
118
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
170
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions${workspaceQuery}`, {
119
171
  credentials: "include",
120
172
  });
121
173
  if (!res.ok)
122
174
  return;
123
- const sessions = (await res.json()).sessions ??
124
- [];
175
+ const sessions = (await res.json())
176
+ .sessions ?? [];
177
+ // Multi-workspace (AC-002): list only this workspace's sessions when a
178
+ // scope is active; unscoped (legacy) keeps the full list.
179
+ const scoped = workspaceId
180
+ ? sessions.filter((item) => !item.workspaceId || item.workspaceId === workspaceId)
181
+ : sessions;
125
182
  // API returns newest-first; keep only the recent window in the sidebar.
126
- const keep = sessions.slice(0, CHAT_SESSION_LIST_LIMIT);
127
- const excess = sessions.slice(CHAT_SESSION_LIST_LIMIT);
183
+ const keep = scoped.slice(0, CHAT_SESSION_LIST_LIMIT);
128
184
  setSessionList(keep);
129
- if (excess.length === 0)
130
- return;
131
- await Promise.all(excess.map((item) => fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(item.sessionId)}`, {
132
- method: "DELETE",
133
- credentials: "include",
134
- headers: {
135
- "x-loop-console-confirmation": confirmationToken(),
136
- },
137
- }).catch(() => undefined)));
138
- const activeId = refs.sessionRef.current?.sessionId;
139
- if (activeId && excess.some((item) => item.sessionId === activeId)) {
140
- sessionSelectionVersionRef.current += 1;
141
- refs.sessionRef.current = null;
142
- resetActiveProjection();
143
- setSession(null);
144
- window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
145
- }
146
185
  }
147
186
  catch {
148
187
  /* Chat remains usable without the sidebar index. */
149
188
  }
150
- }, [origin, refs, resetActiveProjection, setSession]);
189
+ }, [origin, workspaceQuery, workspaceId, blocked]);
151
190
  const deleteSession = useCallback(async (sessionId) => {
152
191
  try {
153
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, {
192
+ // AC-002: delete follows the session's durable owner, not the
193
+ // global active workspace.
194
+ const ownerQuery = sessionOwnerQuery(sessionListRef.current.find((item) => item.sessionId === sessionId)
195
+ ?.workspaceId ?? null);
196
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}${ownerQuery}`, {
154
197
  method: "DELETE",
155
198
  credentials: "include",
156
199
  headers: {
@@ -183,11 +226,16 @@ export function useChatSessions(params) {
183
226
  resetActiveProjection,
184
227
  refreshSessionList,
185
228
  setError,
229
+ sessionOwnerQuery,
186
230
  ]);
187
- const activateSession = useCallback(async (sessionId) => {
231
+ const activateSession = useCallback(async (sessionId, ownerWorkspaceId) => {
188
232
  const selectionVersion = ++sessionSelectionVersionRef.current;
233
+ // AC-002: activation targets the session's durable owning workspace —
234
+ // never the global active workspace (no cross-workspace leak when
235
+ // Operate/Inspect switch their scope while a session stays open).
236
+ const ownerQuery = sessionOwnerQuery(ownerWorkspaceId);
189
237
  try {
190
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, { credentials: "include" });
238
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}${ownerQuery}`, { credentials: "include" });
191
239
  const body = (await res.json());
192
240
  if (!res.ok || !body.session)
193
241
  throw new Error(body.error?.message ?? "会话不可用");
@@ -195,12 +243,17 @@ export function useChatSessions(params) {
195
243
  return;
196
244
  const next = {
197
245
  sessionId: body.session.sessionId,
246
+ ...(body.session.workspaceId ? { workspaceId: body.session.workspaceId } : {}),
198
247
  ...runtimeSelectionFromRecord(body.session),
199
248
  };
200
- resetActiveProjection();
249
+ // AC-002: session switch (same or cross workspace) never aborts other
250
+ // sessions' background request controllers; the single /events owner is
251
+ // re-bound by useChatStream's session effect.
252
+ resetActiveProjection({ keepStreams: true });
201
253
  refs.sessionRef.current = next;
202
254
  refs.streamingRef.current = Boolean(body.session.activeTurnId);
203
255
  refs.activeTurnIdRef.current = body.session.activeTurnId ?? null;
256
+ refs.streamingRef.current = Boolean(body.session.activeTurnId);
204
257
  refs.seenEventIdsRef.current = new Set();
205
258
  refs.turnAssistantIdsRef.current = new Map();
206
259
  refs.sealedAssistantIdsRef.current = new Set();
@@ -214,13 +267,19 @@ export function useChatSessions(params) {
214
267
  setPreviews([]);
215
268
  setInput(body.session.composerDraft ?? "");
216
269
  const hydrated = hydrateSessionThread(body.session.messages ?? []);
270
+ hydrated.messages = mergeCompactionMessages(hydrated.messages, body.compactions);
217
271
  applyActivatedSession({
218
272
  messages: hydrated.messages,
219
273
  toolCalls: hydrated.toolCalls,
274
+ sessionStats: body.sessionStats,
275
+ usage: body.usage,
220
276
  streaming: Boolean(body.session.activeTurnId),
221
277
  turnPhase: body.session.turnPhase ??
222
278
  (body.session.activeTurnId ? "running" : "idle"),
223
279
  deferredTurn: body.session.deferredTurn ?? null,
280
+ deferredTurns: body.session.deferredTurns ?? (body.session.deferredTurn ? [body.session.deferredTurn] : []),
281
+ pendingQuestion: body.session.pendingQuestion ?? null,
282
+ todos: body.session.todos ?? [],
224
283
  });
225
284
  window.localStorage.setItem(ACTIVE_CHAT_SESSION_STORAGE_KEY, sessionId);
226
285
  setActivePreviewPath(null);
@@ -241,6 +300,7 @@ export function useChatSessions(params) {
241
300
  resetActiveProjection,
242
301
  setActivePreviewPath,
243
302
  setError,
303
+ sessionOwnerQuery,
244
304
  ]);
245
305
  useEffect(() => {
246
306
  void refreshSessionList();
@@ -275,19 +335,22 @@ export function useChatSessions(params) {
275
335
  // re-read messages and operation refs/facts from the server-owned stores.
276
336
  useEffect(() => {
277
337
  let cancelled = false;
338
+ if (blocked)
339
+ return; // AC-R5-002: unknown workspace — no restore GET
278
340
  const savedSessionId = window.localStorage.getItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
279
341
  if (!savedSessionId)
280
342
  return;
281
343
  const selectionVersion = ++sessionSelectionVersionRef.current;
282
344
  void (async () => {
283
345
  try {
284
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(savedSessionId)}`, { credentials: "include" });
346
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(savedSessionId)}${workspaceQuery}`, { credentials: "include" });
285
347
  if (!res.ok) {
286
348
  window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
287
349
  return;
288
350
  }
289
351
  const body = (await res.json());
290
352
  if (cancelled ||
353
+ blocked ||
291
354
  selectionVersion !== sessionSelectionVersionRef.current ||
292
355
  !body.session)
293
356
  return;
@@ -309,18 +372,21 @@ export function useChatSessions(params) {
309
372
  refs.lastEventIdRef.current = null;
310
373
  setSession(restored);
311
374
  setInput(body.session.composerDraft ?? "");
312
- setMessages((body.session.messages ?? [])
313
- .filter((message) => message.role === "user" || message.role === "assistant")
314
- .map((message) => {
315
- const content = parseAssistantContent(message.content);
316
- return {
317
- id: message.id,
318
- role: message.role,
319
- text: message.text,
320
- createdAt: Date.parse(message.at) || Date.now(),
321
- ...(content && content.length > 0 ? { content } : {}),
322
- };
323
- }));
375
+ const hydrated = hydrateSessionThread(body.session.messages ?? []);
376
+ hydrated.messages = mergeCompactionMessages(hydrated.messages, body.compactions);
377
+ applyActivatedSession({
378
+ messages: hydrated.messages,
379
+ toolCalls: hydrated.toolCalls,
380
+ sessionStats: body.sessionStats,
381
+ usage: body.usage,
382
+ streaming: Boolean(body.session.activeTurnId),
383
+ turnPhase: body.session.turnPhase ??
384
+ (body.session.activeTurnId ? "running" : "idle"),
385
+ deferredTurn: body.session.deferredTurn ?? null,
386
+ deferredTurns: body.session.deferredTurns ?? (body.session.deferredTurn ? [body.session.deferredTurn] : []),
387
+ pendingQuestion: body.session.pendingQuestion ?? null,
388
+ todos: body.session.todos ?? [],
389
+ });
324
390
  }
325
391
  catch {
326
392
  // Recovery is best-effort; a new session remains available.
@@ -329,30 +395,51 @@ export function useChatSessions(params) {
329
395
  return () => {
330
396
  cancelled = true;
331
397
  };
332
- }, [origin, refs, setSession, setInput, setMessages]);
398
+ }, [
399
+ origin,
400
+ workspaceQuery,
401
+ refs,
402
+ setSession,
403
+ setInput,
404
+ applyActivatedSession,
405
+ blocked,
406
+ ]);
333
407
  // The sole production create path delegates to the exported orchestration
334
408
  // helper so its durable commit order is directly traceable and testable.
335
- const createSessionAndPrimeTitleWatcherForHook = useCallback((options) => createSessionAndPrimeTitleWatcher({
336
- origin,
337
- sessionListRef,
338
- setSessionList,
339
- titleWatcher: titleWatcherRef.current,
340
- refreshSessionList,
341
- options,
342
- setCreatingSession,
343
- creatingRef: creatingSessionRef,
344
- }), [origin, refreshSessionList]);
409
+ // AC-003 (2026-08-26): session creation REQUIRES a known workspaceId —
410
+ // either explicit (sidebar create-in-workspace) or the current valid
411
+ // selection. No scope → fail closed client-side (the server would reject
412
+ // with WORKSPACE_REQUIRED anyway); never a default-workspace fallback.
413
+ const createSessionAndPrimeTitleWatcherForHook = useCallback((options) => {
414
+ const scope = options?.workspaceId?.trim() ?? workspaceId?.trim();
415
+ if (!scope) {
416
+ setError("创建会话需要选择一个有效的工作区(不会回退默认工作区)");
417
+ return Promise.reject(new Error("WORKSPACE_REQUIRED"));
418
+ }
419
+ return createSessionAndPrimeTitleWatcher({
420
+ origin,
421
+ sessionListRef,
422
+ setSessionList,
423
+ titleWatcher: titleWatcherRef.current,
424
+ refreshSessionList,
425
+ options: { ...options, workspaceId: scope },
426
+ setCreatingSession,
427
+ creatingRef: creatingSessionRef,
428
+ });
429
+ }, [origin, refreshSessionList, workspaceId, setError]);
345
430
  const createSession = useCallback(async (options) => {
346
431
  // Suppress concurrent create from any entry (sidebar / Landing / chip).
347
432
  if (creatingSessionRef.current)
348
433
  return null;
434
+ if (blocked)
435
+ return null; // AC-R5-002: unknown workspace — no POST
349
436
  const selectionVersion = ++sessionSelectionVersionRef.current;
350
437
  setError(null);
351
438
  try {
352
439
  const created = await createSessionAndPrimeTitleWatcherForHook(options);
353
440
  if (selectionVersion !== sessionSelectionVersionRef.current)
354
441
  return null;
355
- resetActiveProjection();
442
+ resetActiveProjection({ keepStreams: true });
356
443
  // Set ref synchronously BEFORE setState so a chained
357
444
  // createSession().then(sendPrompt) reads the new session even
358
445
  // though sendPrompt's closure captured the stale (null) value.
@@ -376,6 +463,7 @@ export function useChatSessions(params) {
376
463
  setError,
377
464
  resetActiveProjection,
378
465
  createSessionAndPrimeTitleWatcherForHook,
466
+ blocked,
379
467
  ]);
380
468
  // Phase-1: fork / in-session branch / mainline switch UI deferred; API routes remain.
381
469
  /** Single compact helper for toolbar + `/compact <instructions>` (AC-1). */
@@ -392,7 +480,8 @@ export function useChatSessions(params) {
392
480
  if (trimmed) {
393
481
  headers["content-type"] = "application/json";
394
482
  }
395
- const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(current.sessionId)}/compact`, {
483
+ // AC-002: compaction follows the session's durable owner.
484
+ const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(current.sessionId)}/compact${sessionOwnerQuery()}`, {
396
485
  method: "POST",
397
486
  credentials: "include",
398
487
  headers,
@@ -424,19 +513,23 @@ export function useChatSessions(params) {
424
513
  const tokensBefore = body.snapshot?.tokensBefore;
425
514
  const summaryText = summary || "历史上下文已精简,关键决策与当前任务已保留。";
426
515
  await activateSession(current.sessionId);
427
- setMessages((messages) => [
428
- ...messages,
429
- {
430
- id: `compaction-${Date.now()}`,
431
- role: "assistant",
432
- kind: "compaction",
433
- text: summaryText,
434
- createdAt: Date.now(),
435
- ...(tokensBefore
436
- ? { compaction: { tokensBefore } }
437
- : {}),
438
- },
439
- ]);
516
+ setMessages((messages) => {
517
+ const eventId = typeof body.eventId === "string" ? body.eventId : undefined;
518
+ const id = eventId ? `compaction-${eventId}` : `compaction-${Date.now()}`;
519
+ if (messages.some((message) => message.id === id))
520
+ return messages;
521
+ return [...messages, {
522
+ id,
523
+ role: "assistant",
524
+ kind: "compaction",
525
+ text: summaryText,
526
+ createdAt: Date.now(),
527
+ compaction: {
528
+ ...(tokensBefore === undefined ? {} : { tokensBefore }),
529
+ ...(eventId ? { eventId } : {}),
530
+ },
531
+ }];
532
+ });
440
533
  setNotice({
441
534
  id: Date.now(),
442
535
  message: `已压缩上下文:${summaryText.slice(0, 72)}${summaryText.length > 72 ? "…" : ""}`,
@@ -456,9 +549,12 @@ export function useChatSessions(params) {
456
549
  setMessages,
457
550
  onCompactionChange,
458
551
  activateSession,
552
+ sessionOwnerQuery,
459
553
  ]);
460
554
  const updateSession = async (sessionId, patch) => {
461
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, {
555
+ // AC-002: rename/archive follows the session's durable owner.
556
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}${sessionOwnerQuery(sessionListRef.current.find((item) => item.sessionId === sessionId)
557
+ ?.workspaceId ?? null)}`, {
462
558
  method: "PATCH",
463
559
  credentials: "include",
464
560
  headers: {
@@ -57,7 +57,7 @@ function clearEmptyAssistant(setMessages, assistantId) {
57
57
  * detached Turn resource only; it never opens a second inline SSE consumer. */
58
58
  export function useChatStream(params) {
59
59
  const { origin, refs, session, thread, composer, setError, setAutoScroll, setNotice, recoveryVersion = 0, watchSessionTitle, } = params;
60
- const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, setTurnPhase, setDeferredTurn, } = thread;
60
+ const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, setTurnPhase, setDeferredTurn, setDeferredTurns, } = thread;
61
61
  const { setInput, setPendingImages, recordHistory } = composer;
62
62
  const showNotice = useCallback((message) => {
63
63
  if (!setNotice)
@@ -386,17 +386,18 @@ export function useChatStream(params) {
386
386
  const activeSession = refs.sessionRef.current;
387
387
  if (!activeSession || (!input.text.trim() && input.images.length === 0))
388
388
  return { action: "handled" };
389
- const existing = thread.deferredTurn;
390
389
  showNotice("正在排队…");
391
- const acceptDeferred = (deferredTurn) => {
392
- setDeferredTurn(deferredTurn);
390
+ const acceptDeferred = (deferredTurn, deferredTurns) => {
391
+ setDeferredTurn(deferredTurns?.[0] ?? deferredTurn);
392
+ setDeferredTurns(deferredTurns ?? [deferredTurn]);
393
393
  setInput("");
394
394
  setPendingImages([]);
395
395
  setError(null);
396
- showNotice(existing ? "已更新排队消息" : "已排队,将在当前回合结束后自动发送");
396
+ showNotice("已排队,将在当前回合结束后自动发送");
397
397
  };
398
398
  const acceptDispatched = () => {
399
399
  setDeferredTurn(null);
400
+ setDeferredTurns([]);
400
401
  setInput("");
401
402
  setPendingImages([]);
402
403
  setError(null);
@@ -415,7 +416,6 @@ export function useChatStream(params) {
415
416
  blockedByTurnId: input.blockedByTurnId,
416
417
  text: input.text,
417
418
  images: input.images,
418
- ...(existing ? { expectedRevision: existing.revision } : {}),
419
419
  }),
420
420
  });
421
421
  const body = (await response.json().catch(() => ({})));
@@ -425,9 +425,11 @@ export function useChatStream(params) {
425
425
  for (let attempt = 0; attempt < 2; attempt += 1) {
426
426
  const { response, body } = await putOnce();
427
427
  if (response.ok && body.accepted && body.deferredTurn) {
428
- acceptDeferred(body.deferredTurn);
428
+ acceptDeferred(body.deferredTurn, body.deferredTurns);
429
429
  return { action: "handled" };
430
430
  }
431
+ if (body.deferredTurns)
432
+ setDeferredTurns(body.deferredTurns);
431
433
  if (body.deferredTurn)
432
434
  setDeferredTurn(body.deferredTurn);
433
435
  if (body.error?.code !== "TURN_NOT_ACTIVE") {
@@ -442,7 +444,7 @@ export function useChatStream(params) {
442
444
  state: canonical ?? undefined,
443
445
  });
444
446
  if (decision.action === "deferred") {
445
- acceptDeferred(decision.deferredTurn);
447
+ acceptDeferred(decision.deferredTurn, canonical?.deferredTurns);
446
448
  return { action: "handled" };
447
449
  }
448
450
  if (decision.action === "accepted") {
@@ -451,7 +453,7 @@ export function useChatStream(params) {
451
453
  }
452
454
  if (decision.action === "retry-deferred" && attempt === 0)
453
455
  continue;
454
- if (decision.action === "send-now" && !existing) {
456
+ if (decision.action === "send-now") {
455
457
  showNotice("上一轮已停止,正在发送当前消息…");
456
458
  return { action: "send-now" };
457
459
  }
@@ -468,14 +470,14 @@ export function useChatStream(params) {
468
470
  state: canonical ?? undefined,
469
471
  });
470
472
  if (decision.action === "deferred") {
471
- acceptDeferred(decision.deferredTurn);
473
+ acceptDeferred(decision.deferredTurn, canonical?.deferredTurns);
472
474
  return { action: "handled" };
473
475
  }
474
476
  if (decision.action === "accepted") {
475
477
  acceptDispatched();
476
478
  return { action: "handled" };
477
479
  }
478
- if (decision.action === "send-now" && !existing) {
480
+ if (decision.action === "send-now") {
479
481
  showNotice("上一轮已停止,正在发送当前消息…");
480
482
  return { action: "send-now" };
481
483
  }
@@ -489,6 +491,7 @@ export function useChatStream(params) {
489
491
  refs,
490
492
  thread.deferredTurn,
491
493
  setDeferredTurn,
494
+ setDeferredTurns,
492
495
  setInput,
493
496
  setPendingImages,
494
497
  setError,
@@ -497,9 +500,11 @@ export function useChatStream(params) {
497
500
  ]);
498
501
  /** DELETE the queued turn; "restore" moves its text back into the composer,
499
502
  * "discard" drops it entirely (2026-08-23 queue chip actions). */
500
- const requestDeferredTurnDelete = useCallback(async (mode) => {
503
+ const requestDeferredTurnDelete = useCallback(async (mode, target) => {
501
504
  const activeSession = refs.sessionRef.current;
502
- const deferred = thread.deferredTurn;
505
+ const deferred = target
506
+ ? thread.deferredTurns.find((item) => item.clientRequestId === target.clientRequestId)
507
+ : thread.deferredTurns[0] ?? thread.deferredTurn;
503
508
  if (!activeSession || !deferred)
504
509
  return;
505
510
  try {
@@ -520,10 +525,12 @@ export function useChatStream(params) {
520
525
  setError(body.error?.message ?? "待发送消息撤回失败");
521
526
  return;
522
527
  }
523
- setDeferredTurn(null);
528
+ const remaining = thread.deferredTurns.filter((item) => item.clientRequestId !== deferred.clientRequestId);
529
+ setDeferredTurns(remaining);
530
+ setDeferredTurn(remaining[0] ?? null);
524
531
  if (mode === "restore") {
525
- setInput(body.deferredTurn?.text ?? "");
526
- setPendingImages(body.deferredTurn?.images ?? []);
532
+ setInput(deferred.text ?? body.deferredTurn?.text ?? "");
533
+ setPendingImages(deferred.images ?? body.deferredTurn?.images ?? []);
527
534
  showNotice("待发送消息已移回输入框");
528
535
  }
529
536
  else {
@@ -538,14 +545,66 @@ export function useChatStream(params) {
538
545
  origin,
539
546
  refs,
540
547
  thread.deferredTurn,
548
+ thread.deferredTurns,
541
549
  setDeferredTurn,
550
+ setDeferredTurns,
542
551
  setInput,
543
552
  setPendingImages,
544
553
  setError,
545
554
  showNotice,
546
555
  ]);
547
556
  const cancelDeferredTurn = useCallback(() => requestDeferredTurnDelete("restore"), [requestDeferredTurnDelete]);
548
- const discardDeferredTurn = useCallback(() => requestDeferredTurnDelete("discard"), [requestDeferredTurnDelete]);
557
+ const discardDeferredTurn = useCallback((target) => requestDeferredTurnDelete("discard", target), [requestDeferredTurnDelete]);
558
+ const editDeferredTurn = useCallback(async (target, text) => {
559
+ const activeSession = refs.sessionRef.current;
560
+ if (!activeSession)
561
+ return;
562
+ const item = thread.deferredTurns.find((entry) => entry.clientRequestId === target.clientRequestId);
563
+ if (!item)
564
+ return;
565
+ try {
566
+ const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn`, {
567
+ method: "PUT",
568
+ headers: { "Content-Type": "application/json", "x-loop-console-confirmation": confirmationToken() },
569
+ credentials: "include",
570
+ body: JSON.stringify({ clientRequestId: target.clientRequestId, blockedByTurnId: item.blockedByTurnId, text, images: item.images ?? [], expectedRevision: target.revision }),
571
+ });
572
+ const body = (await response.json().catch(() => ({})));
573
+ if (!response.ok)
574
+ throw new Error(body.error?.message ?? "编辑排队消息失败");
575
+ if (body.deferredTurns)
576
+ setDeferredTurns(body.deferredTurns);
577
+ if (body.deferredTurn)
578
+ setDeferredTurn(body.deferredTurn);
579
+ setError(null);
580
+ }
581
+ catch (error) {
582
+ setError(error instanceof Error ? error.message : "编辑排队消息失败");
583
+ }
584
+ }, [origin, refs, thread.deferredTurns, setDeferredTurn, setDeferredTurns, setError]);
585
+ const steerDeferredTurn = useCallback(async (target) => {
586
+ const activeSession = refs.sessionRef.current;
587
+ if (!activeSession)
588
+ return;
589
+ try {
590
+ const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/deferred-turn/${encodeURIComponent(target.clientRequestId)}/steer`, {
591
+ method: "POST",
592
+ headers: { "Content-Type": "application/json", "x-loop-console-confirmation": confirmationToken() },
593
+ credentials: "include",
594
+ body: JSON.stringify({ expectedRevision: target.revision }),
595
+ });
596
+ const body = (await response.json().catch(() => ({})));
597
+ if (!response.ok)
598
+ throw new Error(body.error?.message ?? "插话发送失败");
599
+ const remaining = body.deferredTurns ?? thread.deferredTurns.filter((item) => item.clientRequestId !== target.clientRequestId);
600
+ setDeferredTurns(remaining);
601
+ setDeferredTurn(remaining[0] ?? null);
602
+ setError(null);
603
+ }
604
+ catch (error) {
605
+ setError(error instanceof Error ? error.message : "插话发送失败");
606
+ }
607
+ }, [origin, refs, thread.deferredTurns, setDeferredTurn, setDeferredTurns, setError]);
549
608
  const sendPrompt = useCallback(async (text, images) => {
550
609
  const initialSession = refs.sessionRef.current;
551
610
  if (!initialSession || (!text.trim() && images.length === 0))
@@ -988,6 +1047,8 @@ export function useChatStream(params) {
988
1047
  handleStop,
989
1048
  cancelDeferredTurn,
990
1049
  discardDeferredTurn,
1050
+ editDeferredTurn,
1051
+ steerDeferredTurn,
991
1052
  openEventsStream,
992
1053
  };
993
1054
  }