@tea-agent/loop-agent 0.33.6 → 0.33.7-beta.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.
- package/CHANGELOG.md +39 -23
- package/dist/application/task-lifecycle/advance.js +4 -254
- package/dist/application/task-lifecycle/gates.js +0 -50
- package/dist/application/task-lifecycle/observe.js +2 -11
- package/dist/commands/init-upgrade.js +1 -32
- package/dist/commands/init.js +3 -94
- package/dist/executors/shell-executor.js +91 -4
- package/dist/executors/shell-write-guard.js +8 -26
- package/dist/shared/operator/capabilities.js +42 -72
- package/dist/task/source-prepare/index.js +0 -2
- package/dist/task/source-prepare/parse-intent.js +10 -58
- package/dist/task/source-prepare/prepare.js +16 -180
- package/dist/task/source-prepare/reference-integrity.js +2 -18
- package/dist/worker/console/app-data.js +0 -2
- package/dist/worker/console/chat/chat-event-store.js +25 -190
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/pi-console-config.js +32 -250
- package/dist/worker/console/chat/pi-runtime.js +71 -625
- package/dist/worker/console/chat/resource-loader.js +4 -5
- package/dist/worker/console/chat/routes.js +146 -324
- package/dist/worker/console/chat/runtime-context.js +12 -48
- package/dist/worker/console/chat/runtime-selection.js +0 -59
- package/dist/worker/console/chat/shortcuts.js +0 -1
- package/dist/worker/console/chat/tool-adapter.js +3 -9
- package/dist/worker/console/chat/tools.js +1 -5
- package/dist/worker/console/operator-actions.js +68 -559
- package/dist/worker/console/server.js +15 -8
- package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
- package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
- package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
- package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
- package/dist/worker/delivery/final-verification.js +5 -13
- package/dist/worker/delivery/package.js +19 -31
- package/dist/worker/delivery/verification-bundle.js +4 -6
- package/dist/worker/observe/static/operator-chrome.css +2 -5
- package/dist/worker/observe/static/operator-chrome.js +1 -6
- package/dist/worker/observe/static/styles.css +9 -39
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
- package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
- package/dist/workflows/dag/backend-test-module-stem.js +5 -0
- package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
- package/dist/workflows/dag/backend-test-scenario-param.js +552 -124
- package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
- package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
- package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
- package/dist/workflows/dag/init-hybrid.js +49 -37
- package/dist/workflows/dag/types.js +7 -0
- package/dist/workflows/dag/workspace-checkpoint.js +27 -8
- package/docs/templates/backend-test-dag.json +33 -30
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -3
- package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
- package/skills/loop-agent/references/task-workflow.md +0 -4
- package/dist/shared/resilient-git.js +0 -133
- package/dist/task/source-prepare/artifact-meta.js +0 -137
- package/dist/task/source-prepare/semantic-intake.js +0 -404
- package/dist/worker/console/dag-execution-receipt.js +0 -380
- package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
- package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
- 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
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
const assistantId = requestId("a");
|
|
161
37
|
refs.assistantIdRef.current = assistantId;
|
|
162
|
-
setStreaming(true);
|
|
163
38
|
setStreamingAssistantId(assistantId);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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/${
|
|
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:
|
|
64
|
+
signal: ac.signal,
|
|
184
65
|
});
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 (
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
refs.assistantIdRef.current
|
|
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
|
-
//
|
|
239
|
-
//
|
|
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
|
|
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(
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
//
|
|
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 = () =>
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
|
301
|
-
|
|
277
|
+
const ac = new AbortController();
|
|
278
|
+
refs.reconnectAbortRef.current = ac;
|
|
279
|
+
void openEventsStream(session.sessionId, ac.signal);
|
|
302
280
|
return () => {
|
|
303
|
-
|
|
304
|
-
if (
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
}
|