@tea-agent/loop-agent 0.33.5 → 0.33.6
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.
- package/CHANGELOG.md +33 -0
- package/dist/application/task-lifecycle/advance.js +254 -4
- package/dist/application/task-lifecycle/gates.js +50 -0
- package/dist/application/task-lifecycle/observe.js +11 -2
- package/dist/commands/init-upgrade.js +32 -1
- package/dist/commands/init.js +94 -3
- package/dist/executors/shell-write-guard.js +26 -8
- package/dist/shared/operator/capabilities.js +72 -42
- package/dist/shared/resilient-git.js +133 -0
- package/dist/task/source-prepare/artifact-meta.js +137 -0
- package/dist/task/source-prepare/index.js +2 -0
- package/dist/task/source-prepare/parse-intent.js +58 -10
- package/dist/task/source-prepare/prepare.js +180 -16
- package/dist/task/source-prepare/reference-integrity.js +18 -2
- package/dist/task/source-prepare/semantic-intake.js +404 -0
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +190 -25
- package/dist/worker/console/chat/pi-console-config.js +250 -32
- package/dist/worker/console/chat/pi-runtime.js +625 -71
- package/dist/worker/console/chat/resource-loader.js +5 -4
- package/dist/worker/console/chat/routes.js +324 -146
- package/dist/worker/console/chat/runtime-context.js +48 -12
- package/dist/worker/console/chat/runtime-selection.js +59 -0
- package/dist/worker/console/chat/shortcuts.js +1 -0
- package/dist/worker/console/chat/tool-adapter.js +9 -3
- package/dist/worker/console/chat/tools.js +5 -1
- package/dist/worker/console/dag-execution-receipt.js +380 -0
- package/dist/worker/console/operator-actions.js +559 -68
- package/dist/worker/console/server.js +8 -15
- package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
- package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
- package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
- package/dist/worker/delivery/final-verification.js +13 -5
- package/dist/worker/delivery/package.js +31 -19
- package/dist/worker/delivery/verification-bundle.js +6 -4
- package/dist/worker/observe/static/operator-chrome.css +5 -2
- package/dist/worker/observe/static/operator-chrome.js +6 -1
- package/dist/worker/observe/static/styles.css +39 -9
- package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
- package/dist/workflows/dag/workspace-checkpoint.js +8 -27
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +3 -1
- package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
- package/skills/loop-agent/references/task-workflow.md +4 -0
- package/dist/worker/console/chat/instruction-skills.js +0 -217
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
- 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
|
-
|
|
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, } = 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
refs.streamingRef.current = true;
|
|
37
161
|
refs.assistantIdRef.current = assistantId;
|
|
162
|
+
setStreaming(true);
|
|
38
163
|
setStreamingAssistantId(assistantId);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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/${
|
|
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,106 @@ export function useChatStream(params) {
|
|
|
61
180
|
data,
|
|
62
181
|
})),
|
|
63
182
|
}),
|
|
64
|
-
signal:
|
|
183
|
+
signal: controller.signal,
|
|
65
184
|
});
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
// 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([]);
|
|
96
195
|
}
|
|
97
|
-
|
|
98
|
-
if (
|
|
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)));
|
|
99
201
|
refs.streamingRef.current = false;
|
|
202
|
+
refs.assistantIdRef.current = null;
|
|
100
203
|
setStreaming(false);
|
|
101
204
|
setStreamingAssistantId(null);
|
|
102
|
-
|
|
103
|
-
refs.assistantIdRef.current = null;
|
|
104
|
-
setPendingImages([]);
|
|
205
|
+
setMessages((messages) => messages.filter((message) => message.id !== assistantId || message.text.trim()));
|
|
105
206
|
}
|
|
106
|
-
|
|
107
|
-
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
refs.sessionAbortControllersRef.current.delete(activeSession.sessionId);
|
|
210
|
+
if (refs.abortRef.current === controller)
|
|
211
|
+
refs.abortRef.current = null;
|
|
108
212
|
}
|
|
109
213
|
}, [
|
|
110
214
|
origin,
|
|
111
215
|
refs,
|
|
112
|
-
|
|
216
|
+
ensureEventsConnected,
|
|
113
217
|
setMessages,
|
|
114
|
-
setStreaming,
|
|
115
|
-
setStreamingAssistantId,
|
|
116
218
|
setInput,
|
|
117
|
-
setPendingImages,
|
|
118
219
|
setAutoScroll,
|
|
119
220
|
setError,
|
|
221
|
+
setStreaming,
|
|
222
|
+
setStreamingAssistantId,
|
|
223
|
+
setPendingImages,
|
|
120
224
|
]);
|
|
121
225
|
const handleStop = useCallback(() => {
|
|
122
226
|
refs.abortRef.current?.abort();
|
|
227
|
+
if (refs.activeTurnIdRef.current)
|
|
228
|
+
refs.settledTurnIdsRef.current.add(refs.activeTurnIdRef.current);
|
|
123
229
|
refs.streamingRef.current = false;
|
|
230
|
+
refs.activeTurnIdRef.current = null;
|
|
124
231
|
setStreaming(false);
|
|
125
232
|
setStreamingAssistantId(null);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
msg.role === "assistant" &&
|
|
131
|
-
!msg.text.trim())));
|
|
233
|
+
setMessages((messages) => messages.filter((message) => !(message.id === refs.assistantIdRef.current &&
|
|
234
|
+
message.role === "assistant" &&
|
|
235
|
+
!message.text.trim())));
|
|
236
|
+
refs.assistantIdRef.current = null;
|
|
132
237
|
}, [refs, setStreaming, setStreamingAssistantId, setMessages]);
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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.
|
|
238
|
+
// State reconciliation is only a status fence: it never consumes/creates
|
|
239
|
+
// message events, and a generation check discards late responses (M5.3).
|
|
140
240
|
useEffect(() => {
|
|
141
241
|
if (!session || !streaming)
|
|
142
242
|
return;
|
|
143
|
-
const
|
|
243
|
+
const sessionId = session.sessionId;
|
|
244
|
+
const generation = refs.sessionGenerationRef.current;
|
|
144
245
|
let cancelled = false;
|
|
145
246
|
const sync = async () => {
|
|
146
|
-
if (cancelled)
|
|
147
|
-
return;
|
|
148
247
|
try {
|
|
149
|
-
const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(
|
|
150
|
-
|
|
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)
|
|
151
254
|
return;
|
|
152
|
-
const body = (await res.json());
|
|
153
255
|
const activeTurnId = body.state?.activeTurnId ?? null;
|
|
154
256
|
refs.activeTurnIdRef.current = activeTurnId;
|
|
155
257
|
if (activeTurnId !== null || !refs.streamingRef.current)
|
|
156
258
|
return;
|
|
157
|
-
|
|
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;
|
|
259
|
+
const assistantId = refs.assistantIdRef.current;
|
|
165
260
|
refs.streamingRef.current = false;
|
|
261
|
+
refs.assistantIdRef.current = null;
|
|
166
262
|
setStreaming(false);
|
|
167
263
|
setStreamingAssistantId(null);
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
setMessages((m) => m.filter((msg) => !(msg.id === pendingAssistantId &&
|
|
172
|
-
msg.role === "assistant" &&
|
|
173
|
-
!msg.text.trim())));
|
|
264
|
+
setMessages((messages) => messages.filter((message) => !(message.id === assistantId &&
|
|
265
|
+
message.role === "assistant" &&
|
|
266
|
+
!message.text.trim())));
|
|
174
267
|
}
|
|
175
268
|
catch {
|
|
176
|
-
//
|
|
269
|
+
// offline/background case: interval, visibility and online retry.
|
|
177
270
|
}
|
|
178
271
|
};
|
|
179
272
|
const onVisible = () => {
|
|
180
273
|
if (document.visibilityState === "visible")
|
|
181
274
|
void sync();
|
|
182
275
|
};
|
|
183
|
-
const onOnline = () =>
|
|
184
|
-
|
|
185
|
-
};
|
|
186
|
-
const interval = setInterval(() => void sync(), 15_000);
|
|
276
|
+
const onOnline = () => void sync();
|
|
277
|
+
const interval = window.setInterval(() => void sync(), 15_000);
|
|
187
278
|
document.addEventListener("visibilitychange", onVisible);
|
|
188
279
|
window.addEventListener("online", onOnline);
|
|
189
280
|
return () => {
|
|
190
281
|
cancelled = true;
|
|
191
|
-
clearInterval(interval);
|
|
282
|
+
window.clearInterval(interval);
|
|
192
283
|
document.removeEventListener("visibilitychange", onVisible);
|
|
193
284
|
window.removeEventListener("online", onOnline);
|
|
194
285
|
};
|
|
@@ -201,87 +292,22 @@ export function useChatStream(params) {
|
|
|
201
292
|
setStreamingAssistantId,
|
|
202
293
|
setMessages,
|
|
203
294
|
]);
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
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.
|
|
272
297
|
useEffect(() => {
|
|
273
|
-
if (!session
|
|
274
|
-
(streaming &&
|
|
275
|
-
refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
|
|
298
|
+
if (!session)
|
|
276
299
|
return;
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
void openEventsStream(session.sessionId, ac.signal);
|
|
300
|
+
const generation = refs.sessionGenerationRef.current;
|
|
301
|
+
startEventsStream(session.sessionId, generation);
|
|
280
302
|
return () => {
|
|
281
|
-
|
|
282
|
-
if (
|
|
303
|
+
const state = refs.eventsStreamStateRef.current;
|
|
304
|
+
if (state?.sessionId === session.sessionId &&
|
|
305
|
+
state.generation === generation) {
|
|
306
|
+
refs.reconnectAbortRef.current?.abort();
|
|
283
307
|
refs.reconnectAbortRef.current = null;
|
|
308
|
+
refs.eventsStreamStateRef.current = null;
|
|
309
|
+
}
|
|
284
310
|
};
|
|
285
|
-
}, [session,
|
|
311
|
+
}, [session, recoveryVersion, refs, startEventsStream]);
|
|
286
312
|
return { sendPrompt, handleStop, openEventsStream };
|
|
287
313
|
}
|
|
@@ -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
|
}
|