@tea-agent/loop-agent 0.34.3 → 0.34.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/AGENTS.md +8 -3
  2. package/CHANGELOG.md +66 -22
  3. package/README.md +2 -2
  4. package/dist/shared/operator/capabilities.js +0 -7
  5. package/dist/worker/cli.js +21 -27
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/chat-event-store.js +134 -4
  8. package/dist/worker/console/chat/pi-runtime.js +308 -63
  9. package/dist/worker/console/chat/resource-preferences-store.js +152 -0
  10. package/dist/worker/console/chat/routes.js +425 -19
  11. package/dist/worker/console/chat/shortcuts.js +208 -9
  12. package/dist/worker/console/chat/tool-preview.js +162 -5
  13. package/dist/worker/console/chat/turn-execution-registry.js +82 -0
  14. package/dist/worker/console/dag-execution-receipt.js +14 -1
  15. package/dist/worker/console/doctor.js +1 -1
  16. package/dist/worker/console/index.js +1 -0
  17. package/dist/worker/console/open-browser.js +134 -0
  18. package/dist/worker/console/recovery-cta.js +1 -1
  19. package/dist/worker/console/server.js +5 -1
  20. package/dist/worker/console/static/assets/index-qpkysQYW.css +1 -0
  21. package/dist/worker/console/static/assets/index-y980PqtP.js +56 -0
  22. package/dist/worker/console/static/index.html +2 -2
  23. package/dist/worker/console/static-src/chat-markdown-security.js +38 -0
  24. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +1 -3
  25. package/dist/worker/console/static-src/operator-chat/format.js +2 -2
  26. package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
  27. package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
  28. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
  29. package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
  31. package/dist/worker/console/static-src/operator-chat/useChatThread.js +11 -5
  32. package/dist/worker/console/static-src/operator-chat/useComposer.js +81 -3
  33. package/dist/workflows/dag/init-hybrid.js +2 -0
  34. package/docs/architecture/evolution.md +1 -1
  35. package/docs/architecture/system-overview.md +1 -1
  36. package/docs/architecture/worker-and-feature.md +1 -1
  37. package/docs/operations/local-development-environment.md +4 -2
  38. package/docs/templates/branch-merge-report.md +9 -0
  39. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  40. package/docs/templates/evaluation/agents-map-verbose-v0.md +2 -2
  41. package/docs/templates/init-managed-agents.md +2 -2
  42. package/package.json +6 -2
  43. package/skills/agent-worker/SKILL.md +1 -1
  44. package/skills/agent-worker/references/agent-worker-operator.md +2 -2
  45. package/skills/loop-agent/references/command-reference.md +2 -3
  46. package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
  47. 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(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`, { headers, credentials: "include", signal });
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: requestId("u"),
412
+ id: userMessageId,
147
413
  role: "user",
148
- text: text.trim(),
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 controller = new AbortController();
165
- refs.abortRef.current = controller;
166
- refs.sessionAbortControllersRef.current.set(activeSession.sessionId, controller);
167
- try {
168
- const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(activeSession.sessionId)}/turns`, {
169
- method: "POST",
170
- headers: {
171
- "Content-Type": "application/json",
172
- "x-loop-console-confirmation": confirmationToken(),
173
- },
174
- credentials: "include",
175
- body: JSON.stringify({
176
- text: text.trim() || "请查看附件图片。",
177
- images: images.map(({ type, mimeType, data }) => ({
178
- type,
179
- mimeType,
180
- data,
181
- })),
182
- }),
183
- signal: controller.signal,
184
- });
185
- const body = (await res.json().catch(() => ({})));
186
- if (!res.ok || !body.turnId)
187
- throw new Error(body.error?.message ?? `HTTP ${res.status}`);
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
- catch (error) {
201
- if (error?.name !== "AbortError" &&
202
- refs.sessionRef.current?.sessionId === activeSession.sessionId &&
203
- refs.sessionGenerationRef.current === generation) {
204
- setError(formatOperatorUserError(error instanceof Error ? error.message : String(error)));
205
- refs.streamingRef.current = false;
206
- refs.assistantIdRef.current = null;
207
- setStreaming(false);
208
- setStreamingAssistantId(null);
209
- setMessages((messages) => messages.filter((message) => message.id !== assistantId || message.text.trim()));
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
- finally {
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
- refs.abortRef.current?.abort();
232
- if (refs.activeTurnIdRef.current)
233
- refs.settledTurnIdsRef.current.add(refs.activeTurnIdRef.current);
234
- refs.streamingRef.current = false;
235
- refs.activeTurnIdRef.current = null;
236
- setStreaming(false);
237
- setStreamingAssistantId(null);
238
- setMessages((messages) => messages.filter((message) => !(message.id === refs.assistantIdRef.current &&
239
- message.role === "assistant" &&
240
- !message.text.trim())));
241
- refs.assistantIdRef.current = null;
242
- }, [refs, setStreaming, setStreamingAssistantId, setMessages]);
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
- if (!session || !streaming)
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(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`, { credentials: "include" });
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 activeTurnId = body.state?.activeTurnId ?? null;
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
- if (activeTurnId !== null || !refs.streamingRef.current)
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
- refs.streamingRef.current = false;
266
- refs.assistantIdRef.current = null;
267
- setStreaming(false);
268
- setStreamingAssistantId(null);
269
- setMessages((messages) => messages.filter((message) => !(message.id === assistantId &&
270
- message.role === "assistant" &&
271
- !message.text.trim())));
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
- setStreaming,
297
- setStreamingAssistantId,
298
- setMessages,
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.