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

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 (69) hide show
  1. package/CHANGELOG.md +23 -23
  2. package/dist/application/task-lifecycle/advance.js +4 -254
  3. package/dist/application/task-lifecycle/gates.js +0 -50
  4. package/dist/application/task-lifecycle/observe.js +2 -11
  5. package/dist/commands/init-upgrade.js +1 -32
  6. package/dist/commands/init.js +3 -94
  7. package/dist/executors/shell-executor.js +91 -4
  8. package/dist/executors/shell-write-guard.js +8 -26
  9. package/dist/shared/operator/capabilities.js +42 -72
  10. package/dist/task/source-prepare/index.js +0 -2
  11. package/dist/task/source-prepare/parse-intent.js +10 -58
  12. package/dist/task/source-prepare/prepare.js +16 -180
  13. package/dist/task/source-prepare/reference-integrity.js +2 -18
  14. package/dist/worker/console/app-data.js +0 -2
  15. package/dist/worker/console/chat/chat-event-store.js +25 -190
  16. package/dist/worker/console/chat/instruction-skills.js +217 -0
  17. package/dist/worker/console/chat/pi-console-config.js +32 -250
  18. package/dist/worker/console/chat/pi-runtime.js +71 -625
  19. package/dist/worker/console/chat/resource-loader.js +4 -5
  20. package/dist/worker/console/chat/routes.js +146 -324
  21. package/dist/worker/console/chat/runtime-context.js +12 -48
  22. package/dist/worker/console/chat/runtime-selection.js +0 -59
  23. package/dist/worker/console/chat/shortcuts.js +0 -1
  24. package/dist/worker/console/chat/tool-adapter.js +3 -9
  25. package/dist/worker/console/chat/tools.js +1 -5
  26. package/dist/worker/console/operator-actions.js +68 -559
  27. package/dist/worker/console/server.js +15 -8
  28. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  29. package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
  30. package/dist/worker/console/static/index.html +2 -2
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
  32. package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
  33. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
  34. package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
  35. package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
  36. package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
  37. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
  38. package/dist/worker/delivery/final-verification.js +5 -13
  39. package/dist/worker/delivery/package.js +19 -31
  40. package/dist/worker/delivery/verification-bundle.js +4 -6
  41. package/dist/worker/observe/static/operator-chrome.css +2 -5
  42. package/dist/worker/observe/static/operator-chrome.js +1 -6
  43. package/dist/worker/observe/static/styles.css +9 -39
  44. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
  45. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  46. package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
  47. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  48. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  49. package/dist/workflows/dag/backend-test-scenario-param.js +269 -82
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  51. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  52. package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
  53. package/dist/workflows/dag/init-hybrid.js +46 -34
  54. package/dist/workflows/dag/types.js +7 -0
  55. package/dist/workflows/dag/workspace-checkpoint.js +27 -8
  56. package/docs/templates/backend-test-dag.json +32 -29
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +1 -3
  60. package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
  61. package/skills/loop-agent/references/task-workflow.md +0 -4
  62. package/dist/shared/resilient-git.js +0 -133
  63. package/dist/task/source-prepare/artifact-meta.js +0 -137
  64. package/dist/task/source-prepare/semantic-intake.js +0 -404
  65. package/dist/worker/console/dag-execution-receipt.js +0 -380
  66. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  67. package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
  68. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
  69. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +0 -196
@@ -1,175 +1,56 @@
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
- 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. */
4
+ /** Inline prompt SSE + background reconnect GET /events stream (M1-T04/T05). */
16
5
  export function useChatStream(params) {
17
- const { origin, refs, session, thread, composer, setError, setAutoScroll, recoveryVersion = 0, } = params;
6
+ const { origin, refs, session, thread, composer, setError, setAutoScroll } = params;
18
7
  const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
19
8
  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]);
125
9
  const sendPrompt = useCallback(async (text, images) => {
126
- const activeSession = refs.sessionRef.current;
127
- const generation = refs.sessionGenerationRef.current;
128
- if (!activeSession ||
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 ||
129
16
  (!text.trim() && images.length === 0) ||
130
17
  refs.streamingRef.current)
131
18
  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;
145
19
  const userMsg = {
146
20
  id: requestId("u"),
147
21
  role: "user",
148
22
  text: text.trim(),
149
23
  createdAt: Date.now(),
150
24
  };
151
- const assistantId = requestId("a");
152
- setMessages((messages) => [
153
- ...messages,
154
- userMsg,
155
- { id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
156
- ]);
25
+ setMessages((m) => [...m, userMsg]);
157
26
  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);
158
34
  setAutoScroll(true);
159
35
  setError(null);
160
- refs.streamingRef.current = true;
36
+ const assistantId = requestId("a");
161
37
  refs.assistantIdRef.current = assistantId;
162
- setStreaming(true);
163
38
  setStreamingAssistantId(assistantId);
164
- const controller = new AbortController();
165
- refs.abortRef.current = controller;
166
- refs.sessionAbortControllersRef.current.set(activeSession.sessionId, controller);
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);
167
47
  try {
168
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/turns`, {
48
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${session.sessionId}/prompt`, {
169
49
  method: "POST",
170
50
  headers: {
171
51
  "Content-Type": "application/json",
172
52
  "x-loop-console-confirmation": confirmationToken(),
53
+ Accept: "text/event-stream",
173
54
  },
174
55
  credentials: "include",
175
56
  body: JSON.stringify({
@@ -180,106 +61,134 @@ export function useChatStream(params) {
180
61
  data,
181
62
  })),
182
63
  }),
183
- signal: controller.signal,
64
+ signal: ac.signal,
184
65
  });
185
- const body = (await res.json().catch(() => ({})));
186
- if (!res.ok || !body.turnId)
66
+ if (!res.ok || !res.body) {
67
+ const body = (await res.json().catch(() => ({})));
187
68
  throw new Error(body.error?.message ?? `HTTP ${res.status}`);
188
- // Slow POST returns cannot alter a later selection/turn (M5.2).
189
- if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
190
- refs.sessionGenerationRef.current !== generation)
191
- return;
192
- refs.activeTurnIdRef.current = body.turnId;
193
- refs.turnAssistantIdsRef.current.set(body.turnId, assistantId);
194
- setPendingImages([]);
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
+ }
195
88
  }
196
- catch (error) {
197
- if (error?.name !== "AbortError" &&
198
- refs.sessionRef.current?.sessionId === activeSession.sessionId &&
199
- refs.sessionGenerationRef.current === generation) {
200
- setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
201
- refs.streamingRef.current = false;
202
- refs.assistantIdRef.current = null;
203
- setStreaming(false);
204
- setStreamingAssistantId(null);
205
- setMessages((messages) => messages.filter((message) => message.id !== assistantId || message.text.trim()));
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)));
206
95
  }
207
96
  }
208
97
  finally {
209
- refs.sessionAbortControllersRef.current.delete(activeSession.sessionId);
210
- if (refs.abortRef.current === controller)
98
+ if (refs.sessionRef.current?.sessionId === session.sessionId) {
99
+ refs.streamingRef.current = false;
100
+ setStreaming(false);
101
+ setStreamingAssistantId(null);
211
102
  refs.abortRef.current = null;
103
+ refs.assistantIdRef.current = null;
104
+ setPendingImages([]);
105
+ }
106
+ refs.inlineStreamSessionIdsRef.current.delete(session.sessionId);
107
+ refs.sessionAbortControllersRef.current.delete(session.sessionId);
212
108
  }
213
109
  }, [
214
110
  origin,
215
111
  refs,
216
- ensureEventsConnected,
112
+ applySseBlock,
217
113
  setMessages,
218
- setInput,
219
- setAutoScroll,
220
- setError,
221
114
  setStreaming,
222
115
  setStreamingAssistantId,
116
+ setInput,
223
117
  setPendingImages,
118
+ setAutoScroll,
119
+ setError,
224
120
  ]);
225
121
  const handleStop = useCallback(() => {
226
122
  refs.abortRef.current?.abort();
227
- if (refs.activeTurnIdRef.current)
228
- refs.settledTurnIdsRef.current.add(refs.activeTurnIdRef.current);
229
123
  refs.streamingRef.current = false;
230
- refs.activeTurnIdRef.current = null;
231
124
  setStreaming(false);
232
125
  setStreamingAssistantId(null);
233
- setMessages((messages) => messages.filter((message) => !(message.id === refs.assistantIdRef.current &&
234
- message.role === "assistant" &&
235
- !message.text.trim())));
236
- refs.assistantIdRef.current = 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())));
237
132
  }, [refs, setStreaming, setStreamingAssistantId, setMessages]);
238
- // State reconciliation is only a status fence: it never consumes/creates
239
- // message events, and a generation check discards late responses (M5.3).
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.
240
140
  useEffect(() => {
241
141
  if (!session || !streaming)
242
142
  return;
243
- const sessionId = session.sessionId;
244
- const generation = refs.sessionGenerationRef.current;
143
+ const sid = session.sessionId;
245
144
  let cancelled = false;
246
145
  const sync = async () => {
146
+ if (cancelled)
147
+ return;
247
148
  try {
248
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`, { credentials: "include" });
249
- const body = (await res.json().catch(() => ({})));
250
- if (cancelled ||
251
- !res.ok ||
252
- refs.sessionRef.current?.sessionId !== sessionId ||
253
- refs.sessionGenerationRef.current !== generation)
149
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sid)}/state`, { credentials: "include" });
150
+ if (!res.ok)
254
151
  return;
152
+ const body = (await res.json());
255
153
  const activeTurnId = body.state?.activeTurnId ?? null;
256
154
  refs.activeTurnIdRef.current = activeTurnId;
257
155
  if (activeTurnId !== null || !refs.streamingRef.current)
258
156
  return;
259
- const assistantId = refs.assistantIdRef.current;
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;
260
165
  refs.streamingRef.current = false;
261
- refs.assistantIdRef.current = null;
262
166
  setStreaming(false);
263
167
  setStreamingAssistantId(null);
264
- setMessages((messages) => messages.filter((message) => !(message.id === assistantId &&
265
- message.role === "assistant" &&
266
- !message.text.trim())));
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())));
267
174
  }
268
175
  catch {
269
- // offline/background case: interval, visibility and online retry.
176
+ // Network still down — the next poll / visibility / online tick retries.
270
177
  }
271
178
  };
272
179
  const onVisible = () => {
273
180
  if (document.visibilityState === "visible")
274
181
  void sync();
275
182
  };
276
- const onOnline = () => void sync();
277
- const interval = window.setInterval(() => void sync(), 15_000);
183
+ const onOnline = () => {
184
+ void sync();
185
+ };
186
+ const interval = setInterval(() => void sync(), 15_000);
278
187
  document.addEventListener("visibilitychange", onVisible);
279
188
  window.addEventListener("online", onOnline);
280
189
  return () => {
281
190
  cancelled = true;
282
- window.clearInterval(interval);
191
+ clearInterval(interval);
283
192
  document.removeEventListener("visibilitychange", onVisible);
284
193
  window.removeEventListener("online", onOnline);
285
194
  };
@@ -292,22 +201,87 @@ export function useChatStream(params) {
292
201
  setStreamingAssistantId,
293
202
  setMessages,
294
203
  ]);
295
- // Exactly one owner starts/aborts GET /events. Switching sessions or
296
- // recovering a cursor aborts the prior stream before the next one is made.
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.
297
272
  useEffect(() => {
298
- if (!session)
273
+ if (!session ||
274
+ (streaming &&
275
+ refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
299
276
  return;
300
- const generation = refs.sessionGenerationRef.current;
301
- startEventsStream(session.sessionId, generation);
277
+ const ac = new AbortController();
278
+ refs.reconnectAbortRef.current = ac;
279
+ void openEventsStream(session.sessionId, ac.signal);
302
280
  return () => {
303
- const state = refs.eventsStreamStateRef.current;
304
- if (state?.sessionId === session.sessionId &&
305
- state.generation === generation) {
306
- refs.reconnectAbortRef.current?.abort();
281
+ ac.abort();
282
+ if (refs.reconnectAbortRef.current === ac)
307
283
  refs.reconnectAbortRef.current = null;
308
- refs.eventsStreamStateRef.current = null;
309
- }
310
284
  };
311
- }, [session, recoveryVersion, refs, startEventsStream]);
285
+ }, [session, streaming, openEventsStream, refs]);
312
286
  return { sendPrompt, handleStop, openEventsStream };
313
287
  }
@@ -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, onReconcile } = params;
18
+ const { origin, refs, sessionId, setError, setProcessOpen } = params;
19
19
  const [messages, setMessages] = useState([]);
20
20
  const [toolCalls, setToolCalls] = useState([]);
21
21
  const [operationCards, setOperationCards] = useState([]);
@@ -35,7 +35,6 @@ export function useChatThread(params) {
35
35
  // applier identity is stable across renders.
36
36
  const applyChatSseEvent = useMemo(() => createChatSseEventApplier({
37
37
  refs,
38
- onReconcile,
39
38
  setError,
40
39
  setProcessOpen,
41
40
  setInterview,
@@ -48,14 +47,12 @@ export function useChatThread(params) {
48
47
  setUsage,
49
48
  setMessages,
50
49
  setToolCalls,
51
- }), [refs, onReconcile, setError, setProcessOpen]);
50
+ }), [refs, setError, setProcessOpen]);
52
51
  /** Parse + apply one raw SSE block (frames separated by \n\n). Returns the
53
52
  * eventId if the block carried an `id:` line (for Last-Event-ID tracking). */
54
53
  const applySseBlock = useCallback((block, assistantId, options) => {
55
- if ((options?.targetSessionId &&
56
- refs.sessionRef.current?.sessionId !== options.targetSessionId) ||
57
- (options?.generation !== undefined &&
58
- refs.sessionGenerationRef.current !== options.generation))
54
+ if (options?.targetSessionId &&
55
+ refs.sessionRef.current?.sessionId !== options.targetSessionId)
59
56
  return null;
60
57
  const lines = block.split("\n");
61
58
  let eventName = "message";
@@ -189,46 +186,6 @@ export function useChatThread(params) {
189
186
  setUnread(0);
190
187
  setHistoryLimit(50);
191
188
  }, [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]);
232
189
  return {
233
190
  messages,
234
191
  setMessages,
@@ -257,6 +214,5 @@ export function useChatThread(params) {
257
214
  loadTaskContext,
258
215
  resetForNewSession,
259
216
  applyActivatedSession,
260
- recoverSession,
261
217
  };
262
218
  }
@@ -1,7 +1,6 @@
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";
5
4
  /** Composer state: input text, @file palette, image attachments, /shortcuts,
6
5
  * IME composition guards, auto-grow and server-side draft sync. */
7
6
  export function useComposer(params) {
@@ -104,20 +103,6 @@ export function useComposer(params) {
104
103
  refs.isComposingRef.current = false;
105
104
  refs.compositionEndedAtRef.current = Date.now();
106
105
  }, [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]);
121
106
  return {
122
107
  input,
123
108
  setInput,
@@ -136,7 +121,5 @@ export function useComposer(params) {
136
121
  textareaRef,
137
122
  onCompositionStart,
138
123
  onCompositionEnd,
139
- isComposingNow,
140
- recentlyComposedNow,
141
124
  };
142
125
  }