@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.1

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 (101) hide show
  1. package/CHANGELOG.md +67 -16
  2. package/dist/application/task-lifecycle/advance.js +309 -9
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/application/task-lifecycle/plan-transitions.js +13 -21
  6. package/dist/commands/init-upgrade.js +32 -1
  7. package/dist/commands/init.js +94 -3
  8. package/dist/executors/dag-pi-executor.js +18 -3
  9. package/dist/executors/shell-executor.js +4 -91
  10. package/dist/executors/shell-write-guard.js +26 -8
  11. package/dist/shared/operator/capabilities.js +98 -44
  12. package/dist/shared/resilient-git.js +133 -0
  13. package/dist/task/source-prepare/artifact-meta.js +137 -0
  14. package/dist/task/source-prepare/index.js +2 -0
  15. package/dist/task/source-prepare/parse-intent.js +58 -10
  16. package/dist/task/source-prepare/prepare.js +229 -18
  17. package/dist/task/source-prepare/reference-integrity.js +18 -2
  18. package/dist/task/source-prepare/semantic-intake.js +404 -0
  19. package/dist/worker/console/app-data.js +2 -0
  20. package/dist/worker/console/chat/chat-event-store.js +190 -25
  21. package/dist/worker/console/chat/model-resolver.js +17 -0
  22. package/dist/worker/console/chat/pi-console-config.js +250 -32
  23. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  24. package/dist/worker/console/chat/resource-loader.js +5 -4
  25. package/dist/worker/console/chat/routes.js +495 -157
  26. package/dist/worker/console/chat/runtime-context.js +48 -12
  27. package/dist/worker/console/chat/runtime-selection.js +59 -0
  28. package/dist/worker/console/chat/session-store.js +39 -0
  29. package/dist/worker/console/chat/shortcuts.js +1 -0
  30. package/dist/worker/console/chat/tool-adapter.js +9 -3
  31. package/dist/worker/console/chat/tools.js +5 -1
  32. package/dist/worker/console/dag-execution-receipt.js +380 -0
  33. package/dist/worker/console/interview/grill-me.js +7 -6
  34. package/dist/worker/console/operator-actions.js +785 -85
  35. package/dist/worker/console/prd-intake-bridge.js +393 -0
  36. package/dist/worker/console/recovery-cta.js +35 -6
  37. package/dist/worker/console/server.js +8 -15
  38. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  39. package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +1 -0
  42. package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
  45. package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
  46. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  47. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  48. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  49. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  50. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  51. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  52. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  53. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  54. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  55. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  56. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  57. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  58. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  59. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  60. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  61. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  62. package/dist/worker/delivery/final-verification.js +13 -5
  63. package/dist/worker/delivery/package.js +31 -19
  64. package/dist/worker/delivery/verification-bundle.js +6 -4
  65. package/dist/worker/observability/read-model.js +3 -0
  66. package/dist/worker/observe/static/operator-chrome.css +5 -2
  67. package/dist/worker/observe/static/operator-chrome.js +6 -1
  68. package/dist/worker/observe/static/styles.css +39 -9
  69. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  70. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  71. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  72. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  73. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  74. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  75. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  76. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  77. package/dist/workflows/dag/failure-category.js +3 -0
  78. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  79. package/dist/workflows/dag/init-hybrid.js +49 -55
  80. package/dist/workflows/dag/node-execution.js +3 -2
  81. package/dist/workflows/dag/retry-policy.js +44 -11
  82. package/dist/workflows/dag/runner.js +6 -0
  83. package/dist/workflows/dag/scheduler.js +49 -1
  84. package/dist/workflows/dag/types.js +0 -7
  85. package/dist/workflows/dag/validate.js +16 -2
  86. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  87. package/docs/templates/agent-dag.schema.json +4 -4
  88. package/docs/templates/backend-test-dag.json +29 -32
  89. package/harness.json +1 -1
  90. package/package.json +1 -1
  91. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  92. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  93. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  94. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  95. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  96. package/skills/loop-agent/references/command-reference.md +3 -1
  97. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  98. package/skills/loop-agent/references/task-workflow.md +4 -0
  99. package/dist/worker/console/chat/instruction-skills.js +0 -217
  100. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  101. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -1,56 +1,175 @@
1
1
  import { useCallback, useEffect } from "react";
2
2
  import { formatOperatorUserError } from "../../operator-user-error.js";
3
3
  import { confirmationToken, requestId, sleep } from "./format.js";
4
- /** Inline prompt SSE + background reconnect GET /events stream (M1-T04/T05). */
4
+ function sseEventName(block) {
5
+ return (block
6
+ .split("\n")
7
+ .find((line) => line.startsWith("event: "))
8
+ ?.slice(7)
9
+ .trim() ?? "message");
10
+ }
11
+ function sleepBriefly() {
12
+ return new Promise((resolve) => window.setTimeout(resolve, 25));
13
+ }
14
+ /** One GET /events stream owns all Chat events. Sending a prompt creates a
15
+ * detached Turn resource only; it never opens a second inline SSE consumer. */
5
16
  export function useChatStream(params) {
6
- const { origin, refs, session, thread, composer, setError, setAutoScroll } = params;
17
+ const { origin, refs, session, thread, composer, setError, setAutoScroll, recoveryVersion = 0, watchSessionTitle, } = params;
7
18
  const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
8
19
  const { setInput, setPendingImages } = composer;
20
+ /** Follow one session's event stream until it is explicitly aborted. The
21
+ * server's `stream_ready` marker separates replay from live events; this
22
+ * means replay cannot render duplicate messages while live text remains
23
+ * smooth (M4.1/M4.3). */
24
+ const openEventsStream = useCallback(async (sessionId, generation, signal) => {
25
+ while (!signal.aborted) {
26
+ const state = refs.eventsStreamStateRef.current;
27
+ if (!state ||
28
+ state.sessionId !== sessionId ||
29
+ state.generation !== generation)
30
+ return;
31
+ state.ready = false;
32
+ try {
33
+ const headers = {
34
+ Accept: "text/event-stream",
35
+ };
36
+ const last = refs.lastEventIdRef.current;
37
+ if (last)
38
+ headers["Last-Event-ID"] = last;
39
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`, { headers, credentials: "include", signal });
40
+ if (!res.ok || !res.body) {
41
+ if (res.status === 404)
42
+ return;
43
+ await sleep(2_000, signal);
44
+ continue;
45
+ }
46
+ const reader = res.body.getReader();
47
+ const decoder = new TextDecoder();
48
+ let buffer = "";
49
+ let replay = true;
50
+ const fallbackAssistantId = requestId("a");
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ buffer += decoder.decode(value, { stream: true });
56
+ const blocks = buffer.split("\n\n");
57
+ buffer = blocks.pop() ?? "";
58
+ for (const block of blocks) {
59
+ if (sseEventName(block) === "stream_ready") {
60
+ const current = refs.eventsStreamStateRef.current;
61
+ if (current?.sessionId === sessionId &&
62
+ current.generation === generation)
63
+ current.ready = true;
64
+ replay = false;
65
+ continue;
66
+ }
67
+ const assistantId = refs.assistantIdRef.current ?? fallbackAssistantId;
68
+ const eventId = applySseBlock(block, assistantId, {
69
+ replay,
70
+ targetSessionId: sessionId,
71
+ generation,
72
+ });
73
+ if (eventId)
74
+ refs.lastEventIdRef.current = eventId;
75
+ }
76
+ }
77
+ const current = refs.eventsStreamStateRef.current;
78
+ if (current?.sessionId === sessionId &&
79
+ current.generation === generation)
80
+ current.ready = false;
81
+ await sleep(1_000, signal);
82
+ }
83
+ catch {
84
+ if (signal.aborted)
85
+ return;
86
+ await sleep(2_000, signal);
87
+ }
88
+ }
89
+ }, [origin, refs, applySseBlock]);
90
+ const startEventsStream = useCallback((sessionId, generation) => {
91
+ const existing = refs.eventsStreamStateRef.current;
92
+ if (existing?.sessionId === sessionId &&
93
+ existing.generation === generation &&
94
+ refs.reconnectAbortRef.current)
95
+ return;
96
+ refs.reconnectAbortRef.current?.abort();
97
+ const controller = new AbortController();
98
+ refs.reconnectAbortRef.current = controller;
99
+ refs.eventsStreamStateRef.current = {
100
+ sessionId,
101
+ generation,
102
+ ready: false,
103
+ };
104
+ void openEventsStream(sessionId, generation, controller.signal);
105
+ }, [refs, openEventsStream]);
106
+ /** Wait until the GET stream completed its retained replay and entered follow
107
+ * mode. The bounded wait gives an actionable error rather than accidentally
108
+ * falling back to a second POST-SSE owner (M4.1). */
109
+ const ensureEventsConnected = useCallback(async (sessionId, generation) => {
110
+ startEventsStream(sessionId, generation);
111
+ const deadline = Date.now() + 5_000;
112
+ while (Date.now() < deadline) {
113
+ const state = refs.eventsStreamStateRef.current;
114
+ if (state?.sessionId === sessionId &&
115
+ state.generation === generation &&
116
+ state.ready)
117
+ return;
118
+ if (refs.sessionRef.current?.sessionId !== sessionId ||
119
+ refs.sessionGenerationRef.current !== generation)
120
+ throw new Error("会话已切换");
121
+ await sleepBriefly();
122
+ }
123
+ throw new Error("事件连接未就绪,请检查 Console 连接后重试");
124
+ }, [refs, startEventsStream]);
9
125
  const sendPrompt = useCallback(async (text, images) => {
10
- // Read from refs (the source of truth) so callers that captured a
11
- // stale closure — notably suggestion chips chaining
12
- // createSession().then(sendPrompt) — still see the freshly created
13
- // session rather than the null from an earlier render.
14
- const session = refs.sessionRef.current;
15
- if (!session ||
126
+ const activeSession = refs.sessionRef.current;
127
+ const generation = refs.sessionGenerationRef.current;
128
+ if (!activeSession ||
16
129
  (!text.trim() && images.length === 0) ||
17
130
  refs.streamingRef.current)
18
131
  return;
132
+ try {
133
+ await ensureEventsConnected(activeSession.sessionId, generation);
134
+ }
135
+ catch (error) {
136
+ if (refs.sessionRef.current?.sessionId === activeSession.sessionId &&
137
+ refs.sessionGenerationRef.current === generation) {
138
+ setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
139
+ }
140
+ return;
141
+ }
142
+ if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
143
+ refs.sessionGenerationRef.current !== generation)
144
+ return;
19
145
  const userMsg = {
20
146
  id: requestId("u"),
21
147
  role: "user",
22
148
  text: text.trim(),
23
149
  createdAt: Date.now(),
24
150
  };
25
- setMessages((m) => [...m, userMsg]);
151
+ const assistantId = requestId("a");
152
+ setMessages((messages) => [
153
+ ...messages,
154
+ userMsg,
155
+ { id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
156
+ ]);
26
157
  setInput("");
27
- // Stop the background replay stream before opening the inline SSE
28
- // stream. Otherwise both connections can consume the same persisted
29
- // event under different assistant ids and render duplicate bubbles.
30
- refs.reconnectAbortRef.current?.abort();
31
- refs.reconnectAbortRef.current = null;
32
- refs.streamingRef.current = true;
33
- setStreaming(true);
34
158
  setAutoScroll(true);
35
159
  setError(null);
36
- const assistantId = requestId("a");
160
+ refs.streamingRef.current = true;
37
161
  refs.assistantIdRef.current = assistantId;
162
+ setStreaming(true);
38
163
  setStreamingAssistantId(assistantId);
39
- setMessages((m) => [
40
- ...m,
41
- { id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
42
- ]);
43
- const ac = new AbortController();
44
- refs.abortRef.current = ac;
45
- refs.sessionAbortControllersRef.current.set(session.sessionId, ac);
46
- refs.inlineStreamSessionIdsRef.current.add(session.sessionId);
164
+ const controller = new AbortController();
165
+ refs.abortRef.current = controller;
166
+ refs.sessionAbortControllersRef.current.set(activeSession.sessionId, controller);
47
167
  try {
48
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${session.sessionId}/prompt`, {
168
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/turns`, {
49
169
  method: "POST",
50
170
  headers: {
51
171
  "Content-Type": "application/json",
52
172
  "x-loop-console-confirmation": confirmationToken(),
53
- Accept: "text/event-stream",
54
173
  },
55
174
  credentials: "include",
56
175
  body: JSON.stringify({
@@ -61,134 +180,111 @@ export function useChatStream(params) {
61
180
  data,
62
181
  })),
63
182
  }),
64
- signal: ac.signal,
183
+ signal: controller.signal,
65
184
  });
66
- if (!res.ok || !res.body) {
67
- const body = (await res.json().catch(() => ({})));
185
+ const body = (await res.json().catch(() => ({})));
186
+ if (!res.ok || !body.turnId)
68
187
  throw new Error(body.error?.message ?? `HTTP ${res.status}`);
69
- }
70
- const reader = res.body.getReader();
71
- const decoder = new TextDecoder();
72
- let buffer = "";
73
- for (;;) {
74
- const { done, value } = await reader.read();
75
- if (done)
76
- break;
77
- buffer += decoder.decode(value, { stream: true });
78
- const events = buffer.split("\n\n");
79
- buffer = events.pop() ?? "";
80
- for (const block of events) {
81
- const eid = applySseBlock(block, assistantId, {
82
- targetSessionId: session.sessionId,
83
- });
84
- if (eid)
85
- refs.lastEventIdRef.current = eid;
86
- }
87
- }
88
- }
89
- catch (e) {
90
- if (e?.name === "AbortError") {
91
- // user stopped; keep partial assistant text
92
- }
93
- else if (refs.sessionRef.current?.sessionId === session.sessionId) {
94
- setError(formatOperatorUserError(e instanceof Error ? e.message : String(e)));
95
- }
188
+ // A successful accepted turn is the browser-side authorization to
189
+ // watch this session's late automatic title. The watcher is independent
190
+ // of the active-session generation, so switching away does not lose it.
191
+ watchSessionTitle(activeSession.sessionId);
192
+ // Slow POST returns cannot alter a later selection/turn (M5.2).
193
+ if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
194
+ refs.sessionGenerationRef.current !== generation)
195
+ return;
196
+ refs.activeTurnIdRef.current = body.turnId;
197
+ refs.turnAssistantIdsRef.current.set(body.turnId, assistantId);
198
+ setPendingImages([]);
96
199
  }
97
- finally {
98
- if (refs.sessionRef.current?.sessionId === session.sessionId) {
200
+ catch (error) {
201
+ if (error?.name !== "AbortError" &&
202
+ refs.sessionRef.current?.sessionId === activeSession.sessionId &&
203
+ refs.sessionGenerationRef.current === generation) {
204
+ setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
99
205
  refs.streamingRef.current = false;
206
+ refs.assistantIdRef.current = null;
100
207
  setStreaming(false);
101
208
  setStreamingAssistantId(null);
102
- refs.abortRef.current = null;
103
- refs.assistantIdRef.current = null;
104
- setPendingImages([]);
209
+ setMessages((messages) => messages.filter((message) => message.id !== assistantId || message.text.trim()));
105
210
  }
106
- refs.inlineStreamSessionIdsRef.current.delete(session.sessionId);
107
- refs.sessionAbortControllersRef.current.delete(session.sessionId);
211
+ }
212
+ finally {
213
+ refs.sessionAbortControllersRef.current.delete(activeSession.sessionId);
214
+ if (refs.abortRef.current === controller)
215
+ refs.abortRef.current = null;
108
216
  }
109
217
  }, [
110
218
  origin,
111
219
  refs,
112
- applySseBlock,
220
+ ensureEventsConnected,
113
221
  setMessages,
114
- setStreaming,
115
- setStreamingAssistantId,
116
222
  setInput,
117
- setPendingImages,
118
223
  setAutoScroll,
119
224
  setError,
225
+ setStreaming,
226
+ setStreamingAssistantId,
227
+ setPendingImages,
228
+ watchSessionTitle,
120
229
  ]);
121
230
  const handleStop = useCallback(() => {
122
231
  refs.abortRef.current?.abort();
232
+ if (refs.activeTurnIdRef.current)
233
+ refs.settledTurnIdsRef.current.add(refs.activeTurnIdRef.current);
123
234
  refs.streamingRef.current = false;
235
+ refs.activeTurnIdRef.current = null;
124
236
  setStreaming(false);
125
237
  setStreamingAssistantId(null);
126
- // User-initiated stop: immediately drop the still-empty placeholder
127
- // bubble (no text yet) instead of leaving a forever "" row (pi-web
128
- // hides empty assistant rows on abort).
129
- setMessages((m) => m.filter((msg) => !(msg.id === refs.assistantIdRef.current &&
130
- msg.role === "assistant" &&
131
- !msg.text.trim())));
238
+ setMessages((messages) => messages.filter((message) => !(message.id === refs.assistantIdRef.current &&
239
+ message.role === "assistant" &&
240
+ !message.text.trim())));
241
+ refs.assistantIdRef.current = null;
132
242
  }, [refs, setStreaming, setStreamingAssistantId, setMessages]);
133
- // Reconcile client streaming state with the server (pi-web get_state loop).
134
- // When SSE events are missed (network drop, backgrounded tab, half-open
135
- // connection) agent_end/agent_settled never arrive and the UI stays
136
- // streaming forever. If the server reports no active turn while we still
137
- // think a turn is streaming, force the same finish a terminal event would
138
- // have produced. Also refreshes activeTurnIdRef so replayed agent_start
139
- // frames are judged against current server truth.
243
+ // State reconciliation is only a status fence: it never consumes/creates
244
+ // message events, and a generation check discards late responses (M5.3).
140
245
  useEffect(() => {
141
246
  if (!session || !streaming)
142
247
  return;
143
- const sid = session.sessionId;
248
+ const sessionId = session.sessionId;
249
+ const generation = refs.sessionGenerationRef.current;
144
250
  let cancelled = false;
145
251
  const sync = async () => {
146
- if (cancelled)
147
- return;
148
252
  try {
149
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sid)}/state`, { credentials: "include" });
150
- if (!res.ok)
253
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`, { credentials: "include" });
254
+ const body = (await res.json().catch(() => ({})));
255
+ if (cancelled ||
256
+ !res.ok ||
257
+ refs.sessionRef.current?.sessionId !== sessionId ||
258
+ refs.sessionGenerationRef.current !== generation)
151
259
  return;
152
- const body = (await res.json());
153
260
  const activeTurnId = body.state?.activeTurnId ?? null;
154
261
  refs.activeTurnIdRef.current = activeTurnId;
155
262
  if (activeTurnId !== null || !refs.streamingRef.current)
156
263
  return;
157
- // Server idle while the client still thinks a turn is streaming.
158
- // Skip when an inline prompt request is still in flight — the
159
- // server may not have created its turn lease yet (POST prompt
160
- // creates the turn server-side after the request lands), and
161
- // force-finishing then would kill a just-sent prompt.
162
- if (refs.inlineStreamSessionIdsRef.current.has(sid))
163
- return;
164
- const pendingAssistantId = refs.assistantIdRef.current;
264
+ const assistantId = refs.assistantIdRef.current;
165
265
  refs.streamingRef.current = false;
266
+ refs.assistantIdRef.current = null;
166
267
  setStreaming(false);
167
268
  setStreamingAssistantId(null);
168
- refs.abortRef.current?.abort();
169
- refs.abortRef.current = null;
170
- refs.assistantIdRef.current = null;
171
- setMessages((m) => m.filter((msg) => !(msg.id === pendingAssistantId &&
172
- msg.role === "assistant" &&
173
- !msg.text.trim())));
269
+ setMessages((messages) => messages.filter((message) => !(message.id === assistantId &&
270
+ message.role === "assistant" &&
271
+ !message.text.trim())));
174
272
  }
175
273
  catch {
176
- // Network still down — the next poll / visibility / online tick retries.
274
+ // offline/background case: interval, visibility and online retry.
177
275
  }
178
276
  };
179
277
  const onVisible = () => {
180
278
  if (document.visibilityState === "visible")
181
279
  void sync();
182
280
  };
183
- const onOnline = () => {
184
- void sync();
185
- };
186
- const interval = setInterval(() => void sync(), 15_000);
281
+ const onOnline = () => void sync();
282
+ const interval = window.setInterval(() => void sync(), 15_000);
187
283
  document.addEventListener("visibilitychange", onVisible);
188
284
  window.addEventListener("online", onOnline);
189
285
  return () => {
190
286
  cancelled = true;
191
- clearInterval(interval);
287
+ window.clearInterval(interval);
192
288
  document.removeEventListener("visibilitychange", onVisible);
193
289
  window.removeEventListener("online", onOnline);
194
290
  };
@@ -201,87 +297,22 @@ export function useChatStream(params) {
201
297
  setStreamingAssistantId,
202
298
  setMessages,
203
299
  ]);
204
- /** Open the reconnectable GET /events stream for a session and apply events
205
- * until it closes, then reopen with the latest Last-Event-ID (M1-T04/T05).
206
- *
207
- * The stream is strictly a recovery path: it is stopped before an inline
208
- * prompt SSE starts, then reopened after that prompt settles. This preserves
209
- * one owner for an in-flight assistant bubble while still replaying the
210
- * durable tail after refreshes and network blips. */
211
- const openEventsStream = useCallback(async (sessionId, signal) => {
212
- while (!signal.aborted) {
213
- try {
214
- const headers = {
215
- Accept: "text/event-stream",
216
- };
217
- const last = refs.lastEventIdRef.current;
218
- if (last)
219
- headers["Last-Event-ID"] = last;
220
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${sessionId}/events`, { headers, credentials: "include", signal });
221
- if (!res.ok || !res.body) {
222
- // 404 = session gone; stop the loop quietly.
223
- if (res.status === 404)
224
- return;
225
- // transient error: back off before retrying.
226
- await sleep(2000, signal);
227
- continue;
228
- }
229
- const reader = res.body.getReader();
230
- const decoder = new TextDecoder();
231
- let buffer = "";
232
- // Reconnect assistant id: events arriving here belong to whichever
233
- // turn is/was active; reuse the in-flight one or allocate a stable
234
- // placeholder so streaming text patches a single bubble.
235
- let rcAssistantId = refs.assistantIdRef.current;
236
- if (!rcAssistantId) {
237
- rcAssistantId = requestId("a");
238
- refs.assistantIdRef.current = rcAssistantId;
239
- }
240
- for (;;) {
241
- const { done, value } = await reader.read();
242
- if (done)
243
- break;
244
- buffer += decoder.decode(value, { stream: true });
245
- const blocks = buffer.split("\n\n");
246
- buffer = blocks.pop() ?? "";
247
- for (const block of blocks) {
248
- const eid = applySseBlock(block, rcAssistantId, {
249
- replay: true,
250
- targetSessionId: sessionId,
251
- });
252
- if (eid)
253
- refs.lastEventIdRef.current = eid;
254
- }
255
- }
256
- // Server closed the follow window (idle). Reopen from the last id
257
- // after a short pause unless we were aborted.
258
- await sleep(1000, signal);
259
- }
260
- catch (e) {
261
- if (signal.aborted)
262
- return;
263
- // Network blip: back off and retry (keeps reconnect resilient).
264
- await sleep(2000, signal);
265
- }
266
- }
267
- }, [origin, refs, applySseBlock]);
268
- // Reconnect effect: open/refresh the events stream whenever the active
269
- // session or streaming state changes. Aborts the previous stream on cleanup
270
- // so switching sessions, starting an inline prompt, or unmounting does not
271
- // leak a dangling connection. It resumes after settle/stop to catch the tail.
300
+ // Exactly one owner starts/aborts GET /events. Switching sessions or
301
+ // recovering a cursor aborts the prior stream before the next one is made.
272
302
  useEffect(() => {
273
- if (!session ||
274
- (streaming &&
275
- refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
303
+ if (!session)
276
304
  return;
277
- const ac = new AbortController();
278
- refs.reconnectAbortRef.current = ac;
279
- void openEventsStream(session.sessionId, ac.signal);
305
+ const generation = refs.sessionGenerationRef.current;
306
+ startEventsStream(session.sessionId, generation);
280
307
  return () => {
281
- ac.abort();
282
- if (refs.reconnectAbortRef.current === ac)
308
+ const state = refs.eventsStreamStateRef.current;
309
+ if (state?.sessionId === session.sessionId &&
310
+ state.generation === generation) {
311
+ refs.reconnectAbortRef.current?.abort();
283
312
  refs.reconnectAbortRef.current = null;
313
+ refs.eventsStreamStateRef.current = null;
314
+ }
284
315
  };
285
- }, [session, streaming, openEventsStream, refs]);
316
+ }, [session, recoveryVersion, refs, startEventsStream]);
286
317
  return { sendPrompt, handleStop, openEventsStream };
287
318
  }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import { buildTurnProcessGroups } from "../../chat/turn-process.js";
3
- import { createChatSseEventApplier } from "./chat-sse-events.js";
3
+ import { createChatSseEventApplier, } from "./chat-sse-events.js";
4
4
  import { taskContextUnchanged } from "./format.js";
5
5
  const EMPTY_USAGE = {
6
6
  inputTokens: 0,
@@ -15,7 +15,7 @@ const EMPTY_USAGE = {
15
15
  * slices it patches and the derived timeline projections.
16
16
  */
17
17
  export function useChatThread(params) {
18
- const { origin, refs, sessionId, setError, setProcessOpen } = params;
18
+ const { origin, refs, sessionId, setError, setProcessOpen, onReconcile } = params;
19
19
  const [messages, setMessages] = useState([]);
20
20
  const [toolCalls, setToolCalls] = useState([]);
21
21
  const [operationCards, setOperationCards] = useState([]);
@@ -35,6 +35,7 @@ export function useChatThread(params) {
35
35
  // applier identity is stable across renders.
36
36
  const applyChatSseEvent = useMemo(() => createChatSseEventApplier({
37
37
  refs,
38
+ onReconcile,
38
39
  setError,
39
40
  setProcessOpen,
40
41
  setInterview,
@@ -47,12 +48,14 @@ export function useChatThread(params) {
47
48
  setUsage,
48
49
  setMessages,
49
50
  setToolCalls,
50
- }), [refs, setError, setProcessOpen]);
51
+ }), [refs, onReconcile, setError, setProcessOpen]);
51
52
  /** Parse + apply one raw SSE block (frames separated by \n\n). Returns the
52
53
  * eventId if the block carried an `id:` line (for Last-Event-ID tracking). */
53
54
  const applySseBlock = useCallback((block, assistantId, options) => {
54
- if (options?.targetSessionId &&
55
- refs.sessionRef.current?.sessionId !== options.targetSessionId)
55
+ if ((options?.targetSessionId &&
56
+ refs.sessionRef.current?.sessionId !== options.targetSessionId) ||
57
+ (options?.generation !== undefined &&
58
+ refs.sessionGenerationRef.current !== options.generation))
56
59
  return null;
57
60
  const lines = block.split("\n");
58
61
  let eventName = "message";
@@ -186,6 +189,46 @@ export function useChatThread(params) {
186
189
  setUnread(0);
187
190
  setHistoryLimit(50);
188
191
  }, [refs]);
192
+ /** Reconcile recovery (AC-001/AC-002/AC-003): reload the canonical session
193
+ * facts (session-store messages + event-ring tail cursor) and REPLACE the
194
+ * thread projection. Never calls applyActivatedSession: that would clear the
195
+ * operation/human-gate/artifact/interview cards, which the reconnected tail
196
+ * stream will not replay (they live in the ring, already consumed). */
197
+ const recoverSession = useCallback(async (targetSessionId) => {
198
+ try {
199
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(targetSessionId)}`, { credentials: "include" });
200
+ if (!res.ok)
201
+ return false;
202
+ const body = (await res.json());
203
+ if (!body.session ||
204
+ refs.sessionRef.current?.sessionId !== targetSessionId)
205
+ return false;
206
+ const activeTurnId = body.session.activeTurnId ?? null;
207
+ refs.activeTurnIdRef.current = activeTurnId;
208
+ refs.streamingRef.current = Boolean(activeTurnId);
209
+ // Resume the events stream from the snapshot tail so the next
210
+ // reconnect never immediately re-expires (AC-003). The refs below
211
+ // are reset so any follow-up tail replay re-applies cleanly.
212
+ refs.lastEventIdRef.current = body.lastEventId ?? null;
213
+ refs.seenEventIdsRef.current = new Set();
214
+ refs.turnAssistantIdsRef.current = new Map();
215
+ setMessages((body.session.messages ?? [])
216
+ .filter((message) => message.role === "user" || message.role === "assistant")
217
+ .map((message) => ({
218
+ id: message.id,
219
+ role: message.role,
220
+ text: message.text,
221
+ createdAt: Date.parse(message.at) || Date.now(),
222
+ })));
223
+ setStreamingAssistantId(null);
224
+ return true;
225
+ }
226
+ catch {
227
+ // Reload failed: keep the old cursor; the next stream reopen will
228
+ // trigger another reconcile and retry (best-effort recovery).
229
+ return false;
230
+ }
231
+ }, [origin, refs, setMessages, setStreamingAssistantId]);
189
232
  return {
190
233
  messages,
191
234
  setMessages,
@@ -214,5 +257,6 @@ export function useChatThread(params) {
214
257
  loadTaskContext,
215
258
  resetForNewSession,
216
259
  applyActivatedSession,
260
+ recoverSession,
217
261
  };
218
262
  }
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react";
2
2
  import { SHORTCUTS } from "../../chat/shortcuts.js";
3
3
  import { confirmationToken } from "./format.js";
4
+ import { attachRuntimeSnapshotController } from "./useRuntimeSnapshot.js";
4
5
  /** Composer state: input text, @file palette, image attachments, /shortcuts,
5
6
  * IME composition guards, auto-grow and server-side draft sync. */
6
7
  export function useComposer(params) {
@@ -103,6 +104,20 @@ export function useComposer(params) {
103
104
  refs.isComposingRef.current = false;
104
105
  refs.compositionEndedAtRef.current = Date.now();
105
106
  }, [refs]);
107
+ /** IME guard used by the ChatComposer keyboard wrapper (mirrors OperatorChat). */
108
+ const isComposingNow = useCallback(() => {
109
+ return refs.isComposingRef.current;
110
+ }, [refs]);
111
+ /** 100ms post-composition window (mirrors OperatorChat). */
112
+ const recentlyComposedNow = useCallback(() => {
113
+ return Date.now() - refs.compositionEndedAtRef.current < 100;
114
+ }, [refs]);
115
+ // Bind the shared runtime snapshot to the active session (the composer owns
116
+ // origin + sessionId + refs in a writable file; the snapshot store is
117
+ // consumed by ChatComposer / panels / runtime-context popover).
118
+ useEffect(() => {
119
+ attachRuntimeSnapshotController({ origin, sessionId, refs });
120
+ }, [origin, sessionId, refs]);
106
121
  return {
107
122
  input,
108
123
  setInput,
@@ -121,5 +136,7 @@ export function useComposer(params) {
121
136
  textareaRef,
122
137
  onCompositionStart,
123
138
  onCompositionEnd,
139
+ isComposingNow,
140
+ recentlyComposedNow,
124
141
  };
125
142
  }