@tea-agent/loop-agent 0.34.3 → 0.34.4
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/AGENTS.md +7 -2
- package/CHANGELOG.md +50 -22
- package/dist/worker/console/app-data.js +2 -0
- package/dist/worker/console/chat/chat-event-store.js +85 -3
- package/dist/worker/console/chat/pi-runtime.js +230 -62
- package/dist/worker/console/chat/resource-preferences-store.js +152 -0
- package/dist/worker/console/chat/routes.js +401 -18
- package/dist/worker/console/chat/turn-execution-registry.js +82 -0
- package/dist/worker/console/dag-execution-receipt.js +14 -1
- package/dist/worker/console/server.js +4 -0
- package/dist/worker/console/static/assets/index-B6Qdbk8V.js +29 -0
- package/dist/worker/console/static/assets/index-Bt0NUxcQ.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
- package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
- package/dist/workflows/dag/init-hybrid.js +2 -0
- package/docs/operations/local-development-environment.md +4 -2
- package/docs/templates/branch-merge-report.md +9 -0
- package/package.json +3 -2
- package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
- package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
|
@@ -1,6 +1,41 @@
|
|
|
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
|
+
import { classifyCreateTurnNetworkFailure, classifyCreateTurnResponse, createPendingSubmission, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, keepAmbiguousAfterRetry, reconcilePendingSubmissionFromState, resolveAmbiguousFromState, } from "./turn-submission.js";
|
|
5
|
+
import { applyAbortDecision, applyPendingReconcileDecision, classifyAbortResponse, markStopRequested, operatorChatAbortUrl, operatorChatEventsUrl, operatorChatStateUrl, operatorChatTurnsUrl, releaseOwnershipOnTerminalEvidence, releaseSubmitOwnership as releaseOwnershipBag, requestStopOnOwnership, settleCreateTurnOutcome, } from "./turn-stream-controller.js";
|
|
6
|
+
/** Project ChatRefs into the executable ownership bag used by the controller. */
|
|
7
|
+
function ownershipBagFromRefs(refs) {
|
|
8
|
+
return {
|
|
9
|
+
submitInFlight: refs.submitInFlightRef.current,
|
|
10
|
+
pendingSubmission: refs.pendingSubmissionRef.current,
|
|
11
|
+
activeTurnId: refs.activeTurnIdRef.current,
|
|
12
|
+
streaming: refs.streamingRef.current,
|
|
13
|
+
assistantId: refs.assistantIdRef.current,
|
|
14
|
+
stoppingTurnId: refs.stoppingTurnIdRef.current,
|
|
15
|
+
settledTurnIds: refs.settledTurnIdsRef.current,
|
|
16
|
+
turnAssistantIds: refs.turnAssistantIdsRef.current,
|
|
17
|
+
sessionId: refs.sessionRef.current?.sessionId ?? null,
|
|
18
|
+
sessionGeneration: refs.sessionGenerationRef.current,
|
|
19
|
+
submitGeneration: refs.submitGenerationRef.current,
|
|
20
|
+
stopInFlight: refs.stopInFlightRef.current,
|
|
21
|
+
stopRequested: refs.stopRequestedRef.current,
|
|
22
|
+
abortPostedTurnIds: refs.abortPostedTurnIdsRef.current,
|
|
23
|
+
releasedClientRequestIds: refs.releasedClientRequestIdsRef.current,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Write controller bag mutations back onto ChatRefs (shared mutable backbone). */
|
|
27
|
+
function applyOwnershipBagToRefs(refs, bag) {
|
|
28
|
+
refs.submitInFlightRef.current = bag.submitInFlight;
|
|
29
|
+
refs.pendingSubmissionRef.current = bag.pendingSubmission;
|
|
30
|
+
refs.activeTurnIdRef.current = bag.activeTurnId;
|
|
31
|
+
refs.streamingRef.current = bag.streaming;
|
|
32
|
+
refs.assistantIdRef.current = bag.assistantId;
|
|
33
|
+
refs.stoppingTurnIdRef.current = bag.stoppingTurnId;
|
|
34
|
+
// settledTurnIds / turnAssistantIds / abortPosted / released sets are same instances.
|
|
35
|
+
refs.submitGenerationRef.current = bag.submitGeneration;
|
|
36
|
+
refs.stopInFlightRef.current = bag.stopInFlight;
|
|
37
|
+
refs.stopRequestedRef.current = bag.stopRequested;
|
|
38
|
+
}
|
|
4
39
|
function sseEventName(block) {
|
|
5
40
|
return (block
|
|
6
41
|
.split("\n")
|
|
@@ -11,12 +46,41 @@ function sseEventName(block) {
|
|
|
11
46
|
function sleepBriefly() {
|
|
12
47
|
return new Promise((resolve) => window.setTimeout(resolve, 25));
|
|
13
48
|
}
|
|
49
|
+
function clearEmptyAssistant(setMessages, assistantId) {
|
|
50
|
+
if (!assistantId)
|
|
51
|
+
return;
|
|
52
|
+
setMessages((messages) => messages.filter((message) => !(message.id === assistantId &&
|
|
53
|
+
message.role === "assistant" &&
|
|
54
|
+
!message.text.trim())));
|
|
55
|
+
}
|
|
14
56
|
/** One GET /events stream owns all Chat events. Sending a prompt creates a
|
|
15
57
|
* detached Turn resource only; it never opens a second inline SSE consumer. */
|
|
16
58
|
export function useChatStream(params) {
|
|
17
|
-
const { origin, refs, session, thread, composer, setError, setAutoScroll, recoveryVersion = 0, watchSessionTitle, } = params;
|
|
59
|
+
const { origin, refs, session, thread, composer, setError, setAutoScroll, setNotice, recoveryVersion = 0, watchSessionTitle, } = params;
|
|
18
60
|
const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
|
|
19
61
|
const { setInput, setPendingImages } = composer;
|
|
62
|
+
const showNotice = useCallback((message) => {
|
|
63
|
+
if (!setNotice)
|
|
64
|
+
return;
|
|
65
|
+
setNotice({ id: Date.now(), message });
|
|
66
|
+
}, [setNotice]);
|
|
67
|
+
const releaseSubmitOwnership = useCallback((pendingClientRequestId) => {
|
|
68
|
+
const bag = ownershipBagFromRefs(refs);
|
|
69
|
+
releaseOwnershipBag(bag, pendingClientRequestId);
|
|
70
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
71
|
+
}, [refs]);
|
|
72
|
+
const markIdleIfCurrent = useCallback((sessionId, generation, assistantId) => {
|
|
73
|
+
if (refs.sessionRef.current?.sessionId !== sessionId ||
|
|
74
|
+
refs.sessionGenerationRef.current !== generation)
|
|
75
|
+
return;
|
|
76
|
+
refs.streamingRef.current = false;
|
|
77
|
+
refs.activeTurnIdRef.current = null;
|
|
78
|
+
refs.assistantIdRef.current = null;
|
|
79
|
+
refs.stoppingTurnIdRef.current = null;
|
|
80
|
+
setStreaming(false);
|
|
81
|
+
setStreamingAssistantId(null);
|
|
82
|
+
clearEmptyAssistant(setMessages, assistantId);
|
|
83
|
+
}, [refs, setStreaming, setStreamingAssistantId, setMessages]);
|
|
20
84
|
/** Follow one session's event stream until it is explicitly aborted. The
|
|
21
85
|
* server's `stream_ready` marker separates replay from live events; this
|
|
22
86
|
* means replay cannot render duplicate messages while live text remains
|
|
@@ -36,7 +100,7 @@ export function useChatStream(params) {
|
|
|
36
100
|
const last = refs.lastEventIdRef.current;
|
|
37
101
|
if (last)
|
|
38
102
|
headers["Last-Event-ID"] = last;
|
|
39
|
-
const res = await fetch(
|
|
103
|
+
const res = await fetch(operatorChatEventsUrl(origin, sessionId), { headers, credentials: "include", signal });
|
|
40
104
|
if (!res.ok || !res.body) {
|
|
41
105
|
if (res.status === 404)
|
|
42
106
|
return;
|
|
@@ -122,13 +186,212 @@ export function useChatStream(params) {
|
|
|
122
186
|
}
|
|
123
187
|
throw new Error("事件连接未就绪,请检查 Console 连接后重试");
|
|
124
188
|
}, [refs, startEventsStream]);
|
|
189
|
+
const fetchSessionState = useCallback(async (sessionId) => {
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(operatorChatStateUrl(origin, sessionId), {
|
|
192
|
+
credentials: "include",
|
|
193
|
+
});
|
|
194
|
+
const body = (await res.json().catch(() => ({})));
|
|
195
|
+
if (!res.ok)
|
|
196
|
+
return null;
|
|
197
|
+
return body.state ?? null;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}, [origin]);
|
|
203
|
+
const postCreateTurn = useCallback(async (input) => {
|
|
204
|
+
try {
|
|
205
|
+
// Real create-turn path remains `${origin}/api/operator/v1/chat/sessions/:id/turns`
|
|
206
|
+
// (via operatorChatTurnsUrl); source contract anchors on /turns`.
|
|
207
|
+
const res = await fetch(operatorChatTurnsUrl(origin, input.sessionId), {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: {
|
|
210
|
+
"Content-Type": "application/json",
|
|
211
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
212
|
+
},
|
|
213
|
+
credentials: "include",
|
|
214
|
+
body: JSON.stringify({
|
|
215
|
+
clientRequestId: input.clientRequestId,
|
|
216
|
+
text: input.text.trim() || "请查看附件图片。",
|
|
217
|
+
images: input.images.map(({ type, mimeType, data }) => ({
|
|
218
|
+
type,
|
|
219
|
+
mimeType,
|
|
220
|
+
data,
|
|
221
|
+
})),
|
|
222
|
+
}),
|
|
223
|
+
});
|
|
224
|
+
const body = (await res.json().catch(() => ({})));
|
|
225
|
+
return { httpOk: res.ok, status: res.status, body };
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return {
|
|
229
|
+
httpOk: false,
|
|
230
|
+
status: 0,
|
|
231
|
+
body: {},
|
|
232
|
+
networkError: true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}, [origin]);
|
|
236
|
+
const noticeSink = useCallback(() => {
|
|
237
|
+
return {
|
|
238
|
+
showNotice,
|
|
239
|
+
setError: (message) => setError(message),
|
|
240
|
+
};
|
|
241
|
+
}, [showNotice, setError]);
|
|
242
|
+
/** Issue real POST /abort for a known turnId (Stop never disposes Session). */
|
|
243
|
+
const postAbortForTurn = useCallback(async (sessionId, generation, turnId) => {
|
|
244
|
+
refs.stoppingTurnIdRef.current = turnId;
|
|
245
|
+
// Keep streaming/busy while abort is in flight (UIS-stopping).
|
|
246
|
+
refs.streamingRef.current = true;
|
|
247
|
+
setStreaming(true);
|
|
248
|
+
try {
|
|
249
|
+
const res = await fetch(operatorChatAbortUrl(origin, sessionId, turnId), {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: {
|
|
252
|
+
"Content-Type": "application/json",
|
|
253
|
+
"x-loop-console-confirmation": confirmationToken(),
|
|
254
|
+
},
|
|
255
|
+
credentials: "include",
|
|
256
|
+
body: "{}",
|
|
257
|
+
});
|
|
258
|
+
const body = (await res.json().catch(() => ({})));
|
|
259
|
+
if (refs.sessionRef.current?.sessionId !== sessionId ||
|
|
260
|
+
refs.sessionGenerationRef.current !== generation)
|
|
261
|
+
return;
|
|
262
|
+
if (refs.stoppingTurnIdRef.current !== turnId)
|
|
263
|
+
return;
|
|
264
|
+
const decision = classifyAbortResponse({
|
|
265
|
+
httpOk: res.ok,
|
|
266
|
+
turnId,
|
|
267
|
+
body,
|
|
268
|
+
});
|
|
269
|
+
const bag = ownershipBagFromRefs(refs);
|
|
270
|
+
bag.stoppingTurnId = turnId;
|
|
271
|
+
if (decision.action === "owner-lost-reconcile") {
|
|
272
|
+
const state = await fetchSessionState(sessionId);
|
|
273
|
+
const stillCurrent = refs.sessionRef.current?.sessionId === sessionId &&
|
|
274
|
+
refs.sessionGenerationRef.current === generation;
|
|
275
|
+
applyAbortDecision(bag, decision, noticeSink(), {
|
|
276
|
+
sessionStillCurrent: stillCurrent,
|
|
277
|
+
runtimeBusy: isRuntimeBusyUnion(state ?? undefined),
|
|
278
|
+
markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
|
|
279
|
+
});
|
|
280
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
applyAbortDecision(bag, decision, noticeSink(), {
|
|
284
|
+
sessionStillCurrent: true,
|
|
285
|
+
markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
|
|
286
|
+
});
|
|
287
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Abort network failure: keep busy (UIS-abort-network-fail).
|
|
291
|
+
// Notice only — never setError / never dispose Session.
|
|
292
|
+
const bag = ownershipBagFromRefs(refs);
|
|
293
|
+
applyAbortDecision(bag, classifyAbortResponse({
|
|
294
|
+
httpOk: false,
|
|
295
|
+
turnId,
|
|
296
|
+
body: {},
|
|
297
|
+
networkError: true,
|
|
298
|
+
}), noticeSink());
|
|
299
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
300
|
+
}
|
|
301
|
+
}, [
|
|
302
|
+
origin,
|
|
303
|
+
refs,
|
|
304
|
+
setStreaming,
|
|
305
|
+
markIdleIfCurrent,
|
|
306
|
+
fetchSessionState,
|
|
307
|
+
noticeSink,
|
|
308
|
+
]);
|
|
309
|
+
/** Production path: settle create-turn via the shared turn-stream controller. */
|
|
310
|
+
const settleOutcome = useCallback((outcome, ctx) => {
|
|
311
|
+
const bag = ownershipBagFromRefs(refs);
|
|
312
|
+
// Keep bag generation fields authoritative for isSubmissionCurrent.
|
|
313
|
+
bag.sessionId = refs.sessionRef.current?.sessionId ?? null;
|
|
314
|
+
bag.sessionGeneration = refs.sessionGenerationRef.current;
|
|
315
|
+
bag.submitGeneration = refs.submitGenerationRef.current;
|
|
316
|
+
const setPendingImagesUi = (images) => {
|
|
317
|
+
setPendingImages(images);
|
|
318
|
+
};
|
|
319
|
+
settleCreateTurnOutcome(bag, outcome, {
|
|
320
|
+
assistantId: ctx.assistantId,
|
|
321
|
+
text: ctx.text,
|
|
322
|
+
images: ctx.images,
|
|
323
|
+
pending: ctx.pending,
|
|
324
|
+
sessionId: ctx.sessionId,
|
|
325
|
+
notice: noticeSink(),
|
|
326
|
+
acceptedUi: {
|
|
327
|
+
setStreaming,
|
|
328
|
+
setStreamingAssistantId,
|
|
329
|
+
setPendingImages: setPendingImagesUi,
|
|
330
|
+
watchSessionTitle,
|
|
331
|
+
},
|
|
332
|
+
adoptUi: {
|
|
333
|
+
clearEmptyAssistant: (assistantId) => clearEmptyAssistant(setMessages, assistantId),
|
|
334
|
+
setInput,
|
|
335
|
+
setPendingImages: setPendingImagesUi,
|
|
336
|
+
setStreaming,
|
|
337
|
+
setStreamingAssistantId,
|
|
338
|
+
},
|
|
339
|
+
rejectedUi: {
|
|
340
|
+
setError,
|
|
341
|
+
setStreaming,
|
|
342
|
+
setStreamingAssistantId,
|
|
343
|
+
clearEmptyAssistant: (assistantId) => clearEmptyAssistant(setMessages, assistantId),
|
|
344
|
+
setInput,
|
|
345
|
+
setPendingImages: setPendingImagesUi,
|
|
346
|
+
formatError: formatOperatorUserError,
|
|
347
|
+
},
|
|
348
|
+
// AC-R5-001: durable stopRequested + accepted/adopt yields one abort.
|
|
349
|
+
onAutoAbort: (turnId) => {
|
|
350
|
+
void postAbortForTurn(ctx.sessionId, ctx.generation, turnId);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
354
|
+
}, [
|
|
355
|
+
refs,
|
|
356
|
+
noticeSink,
|
|
357
|
+
setStreaming,
|
|
358
|
+
setStreamingAssistantId,
|
|
359
|
+
setPendingImages,
|
|
360
|
+
watchSessionTitle,
|
|
361
|
+
setMessages,
|
|
362
|
+
setInput,
|
|
363
|
+
setError,
|
|
364
|
+
postAbortForTurn,
|
|
365
|
+
]);
|
|
125
366
|
const sendPrompt = useCallback(async (text, images) => {
|
|
126
367
|
const activeSession = refs.sessionRef.current;
|
|
127
368
|
const generation = refs.sessionGenerationRef.current;
|
|
128
369
|
if (!activeSession ||
|
|
129
370
|
(!text.trim() && images.length === 0) ||
|
|
130
|
-
refs.streamingRef.current
|
|
371
|
+
refs.streamingRef.current ||
|
|
372
|
+
refs.submitInFlightRef.current ||
|
|
373
|
+
// AC-R5-002: ownership lease blocks send until terminal release.
|
|
374
|
+
Boolean(refs.pendingSubmissionRef.current))
|
|
131
375
|
return;
|
|
376
|
+
// AC-001: acquire synchronous submit ownership before the first await.
|
|
377
|
+
refs.submitInFlightRef.current = true;
|
|
378
|
+
const submitGeneration = ++refs.submitGenerationRef.current;
|
|
379
|
+
const clientRequestId = requestId("chat-submit");
|
|
380
|
+
const userMessageId = requestId("u");
|
|
381
|
+
const assistantId = requestId("a");
|
|
382
|
+
const draftText = text.trim();
|
|
383
|
+
const draftImages = images.slice();
|
|
384
|
+
const pending = createPendingSubmission({
|
|
385
|
+
clientRequestId,
|
|
386
|
+
submitGeneration,
|
|
387
|
+
sessionId: activeSession.sessionId,
|
|
388
|
+
sessionGeneration: generation,
|
|
389
|
+
text: draftText,
|
|
390
|
+
images: draftImages,
|
|
391
|
+
assistantId,
|
|
392
|
+
userMessageId,
|
|
393
|
+
});
|
|
394
|
+
refs.pendingSubmissionRef.current = pending;
|
|
132
395
|
try {
|
|
133
396
|
await ensureEventsConnected(activeSession.sessionId, generation);
|
|
134
397
|
}
|
|
@@ -137,138 +400,322 @@ export function useChatStream(params) {
|
|
|
137
400
|
refs.sessionGenerationRef.current === generation) {
|
|
138
401
|
setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
|
|
139
402
|
}
|
|
403
|
+
releaseSubmitOwnership(clientRequestId);
|
|
140
404
|
return;
|
|
141
405
|
}
|
|
142
406
|
if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
|
|
143
|
-
refs.sessionGenerationRef.current !== generation)
|
|
407
|
+
refs.sessionGenerationRef.current !== generation) {
|
|
408
|
+
releaseSubmitOwnership(clientRequestId);
|
|
144
409
|
return;
|
|
410
|
+
}
|
|
145
411
|
const userMsg = {
|
|
146
|
-
id:
|
|
412
|
+
id: userMessageId,
|
|
147
413
|
role: "user",
|
|
148
|
-
text:
|
|
414
|
+
text: draftText,
|
|
149
415
|
createdAt: Date.now(),
|
|
150
416
|
};
|
|
151
|
-
const assistantId = requestId("a");
|
|
152
417
|
setMessages((messages) => [
|
|
153
418
|
...messages,
|
|
154
419
|
userMsg,
|
|
155
420
|
{ id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
|
|
156
421
|
]);
|
|
157
422
|
setInput("");
|
|
423
|
+
// Attachments stay until accepted (AC/UIS-accepted).
|
|
158
424
|
setAutoScroll(true);
|
|
159
425
|
setError(null);
|
|
160
426
|
refs.streamingRef.current = true;
|
|
161
427
|
refs.assistantIdRef.current = assistantId;
|
|
162
428
|
setStreaming(true);
|
|
163
429
|
setStreamingAssistantId(assistantId);
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
// A successful accepted turn is the browser-side authorization to
|
|
189
|
-
// watch this session's late automatic title. The watcher is independent
|
|
190
|
-
// of the active-session generation, so switching away does not lose it.
|
|
191
|
-
watchSessionTitle(activeSession.sessionId);
|
|
192
|
-
// Slow POST returns cannot alter a later selection/turn (M5.2).
|
|
193
|
-
if (refs.sessionRef.current?.sessionId !== activeSession.sessionId ||
|
|
194
|
-
refs.sessionGenerationRef.current !== generation)
|
|
195
|
-
return;
|
|
196
|
-
refs.activeTurnIdRef.current = body.turnId;
|
|
197
|
-
refs.turnAssistantIdsRef.current.set(body.turnId, assistantId);
|
|
198
|
-
setPendingImages([]);
|
|
430
|
+
const ctx = {
|
|
431
|
+
sessionId: activeSession.sessionId,
|
|
432
|
+
generation,
|
|
433
|
+
submitGeneration,
|
|
434
|
+
assistantId,
|
|
435
|
+
text: draftText,
|
|
436
|
+
images: draftImages,
|
|
437
|
+
pending,
|
|
438
|
+
};
|
|
439
|
+
const first = await postCreateTurn({
|
|
440
|
+
sessionId: activeSession.sessionId,
|
|
441
|
+
clientRequestId,
|
|
442
|
+
text: draftText,
|
|
443
|
+
images: draftImages,
|
|
444
|
+
});
|
|
445
|
+
if (!isSubmissionCurrent({
|
|
446
|
+
pending,
|
|
447
|
+
sessionId: refs.sessionRef.current?.sessionId,
|
|
448
|
+
sessionGeneration: refs.sessionGenerationRef.current,
|
|
449
|
+
submitGeneration: refs.submitGenerationRef.current,
|
|
450
|
+
})) {
|
|
451
|
+
pending.resolveOutcome({ kind: "stale" });
|
|
452
|
+
releaseSubmitOwnership(clientRequestId);
|
|
453
|
+
return;
|
|
199
454
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
455
|
+
let outcome = first.networkError
|
|
456
|
+
? classifyCreateTurnNetworkFailure()
|
|
457
|
+
: classifyCreateTurnResponse({
|
|
458
|
+
httpOk: first.httpOk,
|
|
459
|
+
status: first.status,
|
|
460
|
+
body: first.body,
|
|
461
|
+
clientRequestId,
|
|
462
|
+
});
|
|
463
|
+
if (outcome.kind === "ambiguous") {
|
|
464
|
+
// AC-FIX-001: reconcile by clientRequestId; retry once with same id.
|
|
465
|
+
// Transport uncertainty never becomes rejected / idle.
|
|
466
|
+
const state = await fetchSessionState(activeSession.sessionId);
|
|
467
|
+
const fromState = state
|
|
468
|
+
? resolveAmbiguousFromState({ clientRequestId, state })
|
|
469
|
+
: null;
|
|
470
|
+
if (fromState) {
|
|
471
|
+
outcome = fromState;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
const retry = await postCreateTurn({
|
|
475
|
+
sessionId: activeSession.sessionId,
|
|
476
|
+
clientRequestId,
|
|
477
|
+
text: draftText,
|
|
478
|
+
images: draftImages,
|
|
479
|
+
});
|
|
480
|
+
if (!isSubmissionCurrent({
|
|
481
|
+
pending,
|
|
482
|
+
sessionId: refs.sessionRef.current?.sessionId,
|
|
483
|
+
sessionGeneration: refs.sessionGenerationRef.current,
|
|
484
|
+
submitGeneration: refs.submitGenerationRef.current,
|
|
485
|
+
})) {
|
|
486
|
+
pending.resolveOutcome({ kind: "stale" });
|
|
487
|
+
releaseSubmitOwnership(clientRequestId);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
outcome = retry.networkError
|
|
491
|
+
? classifyCreateTurnNetworkFailure("无法确认 Turn 是否已创建,正在与服务端对账(不会重复提交新的请求 ID)")
|
|
492
|
+
: classifyCreateTurnResponse({
|
|
493
|
+
httpOk: retry.httpOk,
|
|
494
|
+
status: retry.status,
|
|
495
|
+
body: retry.body,
|
|
496
|
+
clientRequestId,
|
|
497
|
+
});
|
|
498
|
+
if (outcome.kind === "ambiguous") {
|
|
499
|
+
// Capture message before reassignment: `outcome` widens back to the full union.
|
|
500
|
+
const ambiguousMessage = outcome.message ||
|
|
501
|
+
"无法确认 Turn 是否已创建,正在与服务端对账(不会重复提交新的请求 ID)";
|
|
502
|
+
outcome = keepAmbiguousAfterRetry(ambiguousMessage);
|
|
503
|
+
showNotice(ambiguousMessage);
|
|
504
|
+
}
|
|
210
505
|
}
|
|
211
506
|
}
|
|
212
|
-
|
|
213
|
-
refs.sessionAbortControllersRef.current.delete(activeSession.sessionId);
|
|
214
|
-
if (refs.abortRef.current === controller)
|
|
215
|
-
refs.abortRef.current = null;
|
|
216
|
-
}
|
|
507
|
+
settleOutcome(outcome, ctx);
|
|
217
508
|
}, [
|
|
218
509
|
origin,
|
|
219
510
|
refs,
|
|
220
511
|
ensureEventsConnected,
|
|
512
|
+
postCreateTurn,
|
|
513
|
+
fetchSessionState,
|
|
514
|
+
settleOutcome,
|
|
515
|
+
releaseSubmitOwnership,
|
|
516
|
+
showNotice,
|
|
221
517
|
setMessages,
|
|
222
518
|
setInput,
|
|
223
519
|
setAutoScroll,
|
|
224
520
|
setError,
|
|
225
521
|
setStreaming,
|
|
226
522
|
setStreamingAssistantId,
|
|
227
|
-
setPendingImages,
|
|
228
|
-
watchSessionTitle,
|
|
229
523
|
]);
|
|
230
|
-
const handleStop = useCallback(() => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
refs.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
524
|
+
const handleStop = useCallback(async () => {
|
|
525
|
+
// AC-R5-001: durable stopRequested is set synchronously before any await so
|
|
526
|
+
// Stop during ambiguous create-turn is never lost. Stop never disposes Session.
|
|
527
|
+
const sessionId = refs.sessionRef.current?.sessionId;
|
|
528
|
+
const generation = refs.sessionGenerationRef.current;
|
|
529
|
+
if (!sessionId)
|
|
530
|
+
return;
|
|
531
|
+
const bag = ownershipBagFromRefs(refs);
|
|
532
|
+
// Decide + mark intent/coalesce only; POST is issued once below.
|
|
533
|
+
const stopResult = requestStopOnOwnership(bag, {
|
|
534
|
+
postAbort: () => { },
|
|
535
|
+
notice: noticeSink(),
|
|
536
|
+
});
|
|
537
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
538
|
+
// Immediate abort already scheduled for a known turnId — coalesce via
|
|
539
|
+
// abortPostedTurnIds; stopInFlight only gates concurrent in-flight POSTs.
|
|
540
|
+
if (stopResult.abortedTurnId) {
|
|
541
|
+
if (refs.stopInFlightRef.current)
|
|
542
|
+
return;
|
|
543
|
+
refs.stopInFlightRef.current = true;
|
|
544
|
+
try {
|
|
545
|
+
await postAbortForTurn(sessionId, generation, stopResult.abortedTurnId);
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
refs.stopInFlightRef.current = false;
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// No turnId yet: durable stopRequested survives until accepted/adopt/bind.
|
|
553
|
+
// Optionally wait on outcomePromise so a racing accepted create still aborts
|
|
554
|
+
// even if settleOutcome's onAutoAbort races; abortPostedTurnIds coalesces.
|
|
555
|
+
const pending = refs.pendingSubmissionRef.current;
|
|
556
|
+
if (!pending)
|
|
557
|
+
return;
|
|
558
|
+
if (refs.stopInFlightRef.current)
|
|
559
|
+
return;
|
|
560
|
+
refs.stopInFlightRef.current = true;
|
|
561
|
+
try {
|
|
562
|
+
const outcome = await pending.outcomePromise;
|
|
563
|
+
if (refs.sessionRef.current?.sessionId !== sessionId ||
|
|
564
|
+
refs.sessionGenerationRef.current !== generation)
|
|
565
|
+
return;
|
|
566
|
+
let turnId = null;
|
|
567
|
+
if (outcome.kind === "accepted")
|
|
568
|
+
turnId = outcome.turnId;
|
|
569
|
+
else if (outcome.kind === "adopt-active")
|
|
570
|
+
turnId = outcome.activeTurn.turnId;
|
|
571
|
+
else if (outcome.kind === "ambiguous") {
|
|
572
|
+
// Intent remains; /state bind-active will fire exactly one abort.
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// rejected / stale — clear durable intent (settle already released).
|
|
577
|
+
refs.stopRequestedRef.current = false;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (!turnId)
|
|
581
|
+
return;
|
|
582
|
+
// settleOutcome may already have auto-aborted; coalesce via bag.
|
|
583
|
+
const lateBag = ownershipBagFromRefs(refs);
|
|
584
|
+
markStopRequested(lateBag);
|
|
585
|
+
const late = requestStopOnOwnership(lateBag, {
|
|
586
|
+
postAbort: () => { },
|
|
587
|
+
notice: noticeSink(),
|
|
588
|
+
});
|
|
589
|
+
applyOwnershipBagToRefs(refs, lateBag);
|
|
590
|
+
if (late.abortedTurnId) {
|
|
591
|
+
await postAbortForTurn(sessionId, generation, late.abortedTurnId);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
refs.stopInFlightRef.current = false;
|
|
596
|
+
}
|
|
597
|
+
}, [refs, noticeSink, postAbortForTurn]);
|
|
243
598
|
// State reconciliation is only a status fence: it never consumes/creates
|
|
244
599
|
// message events, and a generation check discards late responses (M5.3).
|
|
600
|
+
// Busy truth = durable activeTurn union runtime busy facts (AC-003/AC-006).
|
|
601
|
+
// AC-R3-001/002: while pendingSubmissionRef holds a clientRequestId after
|
|
602
|
+
// ambiguous double-POST, continuous /state must bind or release that same
|
|
603
|
+
// id only - never mint a new request id or re-POST Prompt.
|
|
604
|
+
// AC-R5-001/002: stopRequested auto-abort on bind; terminal evidence releases
|
|
605
|
+
// ownership even when React streaming is already false.
|
|
245
606
|
useEffect(() => {
|
|
246
|
-
|
|
607
|
+
// Keep reconcile alive while streaming OR pending ownership OR stop intent.
|
|
608
|
+
const hasOwnershipWork = Boolean(refs.pendingSubmissionRef.current) ||
|
|
609
|
+
refs.submitInFlightRef.current ||
|
|
610
|
+
refs.stopRequestedRef.current ||
|
|
611
|
+
Boolean(refs.activeTurnIdRef.current);
|
|
612
|
+
if (!session || (!streaming && !hasOwnershipWork))
|
|
247
613
|
return;
|
|
248
614
|
const sessionId = session.sessionId;
|
|
249
615
|
const generation = refs.sessionGenerationRef.current;
|
|
250
616
|
let cancelled = false;
|
|
251
617
|
const sync = async () => {
|
|
252
618
|
try {
|
|
253
|
-
const res = await fetch(
|
|
619
|
+
const res = await fetch(operatorChatStateUrl(origin, sessionId), {
|
|
620
|
+
credentials: "include",
|
|
621
|
+
});
|
|
254
622
|
const body = (await res.json().catch(() => ({})));
|
|
255
623
|
if (cancelled ||
|
|
256
624
|
!res.ok ||
|
|
257
625
|
refs.sessionRef.current?.sessionId !== sessionId ||
|
|
258
626
|
refs.sessionGenerationRef.current !== generation)
|
|
259
627
|
return;
|
|
260
|
-
const
|
|
628
|
+
const state = body.state;
|
|
629
|
+
const pending = refs.pendingSubmissionRef.current;
|
|
630
|
+
// Pending ownership reconcile. Controller owns bind/release; same
|
|
631
|
+
// clientRequestId only. Re-enter after acceptedTurnId so terminal
|
|
632
|
+
// can release and stopRequested can auto-abort on bind.
|
|
633
|
+
if (pending) {
|
|
634
|
+
const decision = reconcilePendingSubmissionFromState({
|
|
635
|
+
clientRequestId: pending.clientRequestId,
|
|
636
|
+
state,
|
|
637
|
+
});
|
|
638
|
+
const bag = ownershipBagFromRefs(refs);
|
|
639
|
+
const result = applyPendingReconcileDecision(bag, decision, state, {
|
|
640
|
+
markIdle: () => markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current),
|
|
641
|
+
onAutoAbort: (turnId) => {
|
|
642
|
+
void postAbortForTurn(sessionId, generation, turnId);
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
646
|
+
if (result === "bound") {
|
|
647
|
+
// US-BUSY-ACTIVE-BOUND: keep streaming/busy with same id.
|
|
648
|
+
setStreaming(true);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (result === "released") {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
// hold without acceptedTurnId: keep pending + busy.
|
|
655
|
+
if (!pending.acceptedTurnId && !bag.pendingSubmission?.acceptedTurnId) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
// hold with acceptedTurnId: fall through to terminal fence.
|
|
659
|
+
}
|
|
660
|
+
const activeTurnId = state?.activeTurn?.turnId ?? state?.activeTurnId ?? null;
|
|
261
661
|
refs.activeTurnIdRef.current = activeTurnId;
|
|
262
|
-
|
|
662
|
+
const busy = isRuntimeBusyUnion(state);
|
|
663
|
+
// AC-R5-002: terminal evidence releases ownership even when React
|
|
664
|
+
// streaming is already false (SSE projected idle first).
|
|
665
|
+
const stillPending = refs.pendingSubmissionRef.current;
|
|
666
|
+
if (stillPending) {
|
|
667
|
+
const latestMatches = state?.latestTurn?.clientRequestId ===
|
|
668
|
+
stillPending.clientRequestId &&
|
|
669
|
+
isTerminalTurnState(state?.latestTurn?.state);
|
|
670
|
+
const activeMatches = state?.activeTurn?.clientRequestId ===
|
|
671
|
+
stillPending.clientRequestId &&
|
|
672
|
+
isTerminalTurnState(state?.activeTurn?.state);
|
|
673
|
+
if (latestMatches || activeMatches) {
|
|
674
|
+
const termTurnId = activeMatches
|
|
675
|
+
? (state?.activeTurn?.turnId ?? null)
|
|
676
|
+
: (state?.latestTurn?.turnId ??
|
|
677
|
+
stillPending.acceptedTurnId ??
|
|
678
|
+
null);
|
|
679
|
+
const bag = ownershipBagFromRefs(refs);
|
|
680
|
+
releaseOwnershipOnTerminalEvidence(bag, {
|
|
681
|
+
clientRequestId: stillPending.clientRequestId,
|
|
682
|
+
turnId: termTurnId,
|
|
683
|
+
markIdle: !busy,
|
|
684
|
+
});
|
|
685
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
686
|
+
if (!busy) {
|
|
687
|
+
markIdleIfCurrent(sessionId, generation, refs.assistantIdRef.current);
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (busy)
|
|
263
693
|
return;
|
|
694
|
+
// Only clear when durable Turn is gone AND runtime is not busy.
|
|
695
|
+
// Do not require streamingRef.current === true (AC-R5-002).
|
|
264
696
|
const assistantId = refs.assistantIdRef.current;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
697
|
+
if (activeTurnId) {
|
|
698
|
+
const terminal = isTerminalTurnState(state?.activeTurn?.state) ||
|
|
699
|
+
isTerminalTurnState(state?.activeTurnState);
|
|
700
|
+
if (!terminal)
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (activeTurnId)
|
|
704
|
+
refs.settledTurnIdsRef.current.add(activeTurnId);
|
|
705
|
+
const releaseId = stillPending?.clientRequestId;
|
|
706
|
+
if (releaseId) {
|
|
707
|
+
const bag = ownershipBagFromRefs(refs);
|
|
708
|
+
releaseOwnershipOnTerminalEvidence(bag, {
|
|
709
|
+
clientRequestId: releaseId,
|
|
710
|
+
turnId: activeTurnId,
|
|
711
|
+
markIdle: true,
|
|
712
|
+
});
|
|
713
|
+
applyOwnershipBagToRefs(refs, bag);
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
releaseSubmitOwnership();
|
|
717
|
+
}
|
|
718
|
+
markIdleIfCurrent(sessionId, generation, assistantId);
|
|
272
719
|
}
|
|
273
720
|
catch {
|
|
274
721
|
// offline/background case: interval, visibility and online retry.
|
|
@@ -282,6 +729,8 @@ export function useChatStream(params) {
|
|
|
282
729
|
const interval = window.setInterval(() => void sync(), 15_000);
|
|
283
730
|
document.addEventListener("visibilitychange", onVisible);
|
|
284
731
|
window.addEventListener("online", onOnline);
|
|
732
|
+
// Immediate tick so terminal-before-streaming-false races converge promptly.
|
|
733
|
+
void sync();
|
|
285
734
|
return () => {
|
|
286
735
|
cancelled = true;
|
|
287
736
|
window.clearInterval(interval);
|
|
@@ -293,9 +742,9 @@ export function useChatStream(params) {
|
|
|
293
742
|
refs,
|
|
294
743
|
session,
|
|
295
744
|
streaming,
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
745
|
+
markIdleIfCurrent,
|
|
746
|
+
releaseSubmitOwnership,
|
|
747
|
+
postAbortForTurn,
|
|
299
748
|
]);
|
|
300
749
|
// Exactly one owner starts/aborts GET /events. Switching sessions or
|
|
301
750
|
// recovering a cursor aborts the prior stream before the next one is made.
|