@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.
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Executable Turn stream ownership / notice / abort controller.
3
+ *
4
+ * Production path for useChatStream: tests drive this same module rather than a
5
+ * parallel state machine or source-regex-only contracts.
6
+ *
7
+ * Constraints preserved here:
8
+ * - transport uncertainty is never accepted:false / rejected
9
+ * - same clientRequestId is never reminted during ambiguous retry/reconcile
10
+ * - Stop never disposes Session (no dispose call site in this module)
11
+ * - notice paths (adopt / 正在停止… / abort-pending) never escalate via setError
12
+ * - default request paths target real Operator Chat API (no Mock activation)
13
+ */
14
+ import { createPendingSubmission, isRuntimeBusyUnion, isSubmissionCurrent, isTerminalTurnState, } from "./turn-submission.js";
15
+ /** Real Operator Chat API path templates (production defaults; Mock off). */
16
+ export const OPERATOR_CHAT_REAL_PATHS = {
17
+ turns: "/api/operator/v1/chat/sessions/:sessionId/turns",
18
+ state: "/api/operator/v1/chat/sessions/:sessionId/state",
19
+ events: "/api/operator/v1/chat/sessions/:sessionId/events",
20
+ abort: "/api/operator/v1/chat/sessions/:sessionId/turns/:turnId/abort",
21
+ };
22
+ export function operatorChatTurnsUrl(origin, sessionId) {
23
+ return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/turns`;
24
+ }
25
+ export function operatorChatStateUrl(origin, sessionId) {
26
+ return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/state`;
27
+ }
28
+ export function operatorChatEventsUrl(origin, sessionId) {
29
+ return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/events`;
30
+ }
31
+ export function operatorChatAbortUrl(origin, sessionId, turnId) {
32
+ return `${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/abort`;
33
+ }
34
+ export function createTurnOwnershipBag(seed) {
35
+ return {
36
+ submitInFlight: false,
37
+ pendingSubmission: null,
38
+ activeTurnId: null,
39
+ streaming: false,
40
+ assistantId: null,
41
+ stoppingTurnId: null,
42
+ settledTurnIds: new Set(),
43
+ turnAssistantIds: new Map(),
44
+ sessionId: null,
45
+ sessionGeneration: 0,
46
+ submitGeneration: 0,
47
+ stopInFlight: false,
48
+ stopRequested: false,
49
+ abortPostedTurnIds: new Set(),
50
+ releasedClientRequestIds: new Set(),
51
+ ...seed,
52
+ };
53
+ }
54
+ /** Whether a second concurrent send is blocked by ownership / busy. */
55
+ export function isSubmitOwnershipBusy(bag) {
56
+ return bag.streaming || bag.submitInFlight || Boolean(bag.pendingSubmission);
57
+ }
58
+ /**
59
+ * Release submit ownership. Optionally scoped to a clientRequestId so a stale
60
+ * release cannot clear a newer pending submission.
61
+ * Independent of React streaming projection — terminal evidence always clears
62
+ * pendingSubmission/submitInFlight for the matching clientRequestId.
63
+ */
64
+ export function releaseSubmitOwnership(bag, pendingClientRequestId) {
65
+ const pending = bag.pendingSubmission;
66
+ if (pendingClientRequestId &&
67
+ pending &&
68
+ pending.clientRequestId !== pendingClientRequestId) {
69
+ return;
70
+ }
71
+ bag.submitInFlight = false;
72
+ if (!pendingClientRequestId ||
73
+ pending?.clientRequestId === pendingClientRequestId) {
74
+ if (pending?.clientRequestId) {
75
+ bag.releasedClientRequestIds.add(pending.clientRequestId);
76
+ }
77
+ else if (pendingClientRequestId) {
78
+ bag.releasedClientRequestIds.add(pendingClientRequestId);
79
+ }
80
+ bag.pendingSubmission = null;
81
+ }
82
+ // Ownership release clears durable stop intent for this submission.
83
+ if (!bag.pendingSubmission) {
84
+ bag.stopRequested = false;
85
+ }
86
+ }
87
+ /** Mark durable client Stop intent (survives ambiguous create-turn). */
88
+ export function markStopRequested(bag) {
89
+ bag.stopRequested = true;
90
+ }
91
+ /** Clear durable Stop intent (terminal / no-active / rejected / after abort). */
92
+ export function clearStopRequested(bag) {
93
+ bag.stopRequested = false;
94
+ }
95
+ /**
96
+ * Decide whether a real POST /abort should run for a newly known turnId.
97
+ * Coalesces to one abort per turnId. Never remints clientRequestId.
98
+ */
99
+ export function shouldPostAbortForTurn(bag, turnId) {
100
+ if (!turnId)
101
+ return false;
102
+ if (bag.abortPostedTurnIds.has(turnId))
103
+ return false;
104
+ return true;
105
+ }
106
+ /** Record that a real POST /abort was issued for turnId (idempotent coalesce). */
107
+ export function noteAbortPosted(bag, turnId) {
108
+ if (turnId)
109
+ bag.abortPostedTurnIds.add(turnId);
110
+ }
111
+ /**
112
+ * After accepted / adopt-active / bind-active yields a turnId, if stopRequested
113
+ * is still set, schedule exactly one abort for that turn (caller issues POST).
114
+ * Returns the turnId to abort, or null when no abort is needed.
115
+ */
116
+ export function consumeStopIntentForAbort(bag, turnId) {
117
+ if (!bag.stopRequested)
118
+ return null;
119
+ if (!shouldPostAbortForTurn(bag, turnId))
120
+ return null;
121
+ noteAbortPosted(bag, turnId);
122
+ bag.stoppingTurnId = turnId;
123
+ bag.activeTurnId = turnId;
124
+ // Intent stays until terminal/no-active/rejected clears it; abort in-flight
125
+ // still represents the same durable client Stop.
126
+ return turnId;
127
+ }
128
+ /**
129
+ * Release ownership lease from terminal evidence even when React streaming is
130
+ * already false (SSE may have projected idle before /state bind). Scoped by
131
+ * clientRequestId so a newer pending submission is never cleared.
132
+ */
133
+ export function releaseOwnershipOnTerminalEvidence(bag, input) {
134
+ const pending = bag.pendingSubmission;
135
+ if (pending && pending.clientRequestId !== input.clientRequestId) {
136
+ return false;
137
+ }
138
+ if (input.turnId) {
139
+ bag.settledTurnIds.add(input.turnId);
140
+ }
141
+ releaseSubmitOwnership(bag, input.clientRequestId);
142
+ clearStopRequested(bag);
143
+ if (input.markIdle !== false) {
144
+ // Streaming may already be false; still clear ownership-related fields.
145
+ bag.streaming = false;
146
+ bag.activeTurnId = null;
147
+ bag.assistantId = null;
148
+ bag.stoppingTurnId = null;
149
+ }
150
+ return true;
151
+ }
152
+ /** Acquire synchronous submit ownership before the first await (AC-001). */
153
+ export function beginSubmitOwnership(bag, input) {
154
+ bag.submitInFlight = true;
155
+ const submitGeneration = ++bag.submitGeneration;
156
+ const pending = createPendingSubmission({
157
+ clientRequestId: input.clientRequestId,
158
+ submitGeneration,
159
+ sessionId: input.sessionId,
160
+ sessionGeneration: input.sessionGeneration,
161
+ text: input.text,
162
+ images: input.images,
163
+ assistantId: input.assistantId,
164
+ userMessageId: input.userMessageId,
165
+ });
166
+ bag.pendingSubmission = pending;
167
+ bag.sessionId = input.sessionId;
168
+ bag.sessionGeneration = input.sessionGeneration;
169
+ return pending;
170
+ }
171
+ export function applyAcceptedOwnership(bag, input) {
172
+ if (!isSubmissionCurrent({
173
+ pending: input.pending,
174
+ sessionId: bag.sessionId,
175
+ sessionGeneration: bag.sessionGeneration,
176
+ submitGeneration: bag.submitGeneration,
177
+ })) {
178
+ input.pending.resolveOutcome({ kind: "stale" });
179
+ return false;
180
+ }
181
+ input.pending.acceptedTurnId = input.turnId;
182
+ bag.activeTurnId = input.turnId;
183
+ bag.turnAssistantIds.set(input.turnId, input.assistantId);
184
+ bag.streaming = true;
185
+ // Keep pendingSubmission until terminal/idle so Stop-before-202 can observe it.
186
+ // Drop only the in-flight gate for new sends after acceptance.
187
+ bag.submitInFlight = false;
188
+ input.pending.resolveOutcome({
189
+ kind: "accepted",
190
+ turnId: input.turnId,
191
+ });
192
+ input.ui?.setStreaming(true);
193
+ input.ui?.setStreamingAssistantId(input.assistantId);
194
+ input.ui?.setPendingImages([]);
195
+ input.ui?.watchSessionTitle?.(input.sessionId);
196
+ return true;
197
+ }
198
+ /**
199
+ * TURN_ACTIVE adopt: non-fatal notice only. Never escalates via setError(message).
200
+ * Preserves local draft and binds the active turn.
201
+ */
202
+ export function applyAdoptActiveOwnership(bag, input) {
203
+ input.pending.resolveOutcome({
204
+ kind: "adopt-active",
205
+ activeTurn: { turnId: input.activeTurnId },
206
+ message: input.message,
207
+ });
208
+ releaseSubmitOwnership(bag, input.pending.clientRequestId);
209
+ if (!input.current)
210
+ return;
211
+ input.ui?.clearEmptyAssistant(input.assistantId);
212
+ input.ui?.setInput(input.text);
213
+ input.ui?.setPendingImages(input.images);
214
+ bag.activeTurnId = input.activeTurnId;
215
+ bag.assistantId = null;
216
+ bag.streaming = true;
217
+ input.ui?.setStreaming(true);
218
+ input.ui?.setStreamingAssistantId(null);
219
+ // Clear any prior fatal error; adopt is non-fatal.
220
+ input.notice.setError(null);
221
+ input.notice.showNotice(input.message);
222
+ }
223
+ export function applyRejectedOwnership(bag, input) {
224
+ input.pending.resolveOutcome({
225
+ kind: "rejected",
226
+ message: input.message,
227
+ });
228
+ releaseSubmitOwnership(bag, input.pending.clientRequestId);
229
+ if (!input.current)
230
+ return;
231
+ const formatted = input.ui?.formatError
232
+ ? input.ui.formatError(input.message)
233
+ : input.message;
234
+ input.ui?.setError(formatted);
235
+ bag.streaming = false;
236
+ bag.assistantId = null;
237
+ input.ui?.setStreaming(false);
238
+ input.ui?.setStreamingAssistantId(null);
239
+ input.ui?.clearEmptyAssistant(input.assistantId);
240
+ input.ui?.setInput(input.text);
241
+ input.ui?.setPendingImages(input.images);
242
+ }
243
+ /**
244
+ * Settle a create-turn outcome onto ownership.
245
+ * Ambiguous leaves pending/busy for reconcile — never applyRejected / release.
246
+ */
247
+ export function settleCreateTurnOutcome(bag, outcome, ctx) {
248
+ const current = isSubmissionCurrent({
249
+ pending: ctx.pending,
250
+ sessionId: bag.sessionId,
251
+ sessionGeneration: bag.sessionGeneration,
252
+ submitGeneration: bag.submitGeneration,
253
+ });
254
+ if (outcome.kind === "accepted") {
255
+ const applied = applyAcceptedOwnership(bag, {
256
+ turnId: outcome.turnId,
257
+ assistantId: ctx.assistantId,
258
+ pending: ctx.pending,
259
+ sessionId: ctx.sessionId,
260
+ ui: ctx.acceptedUi,
261
+ });
262
+ // AC-R5-001: Stop during create → exactly one abort once turnId is known.
263
+ if (applied) {
264
+ const abortTurnId = consumeStopIntentForAbort(bag, outcome.turnId);
265
+ if (abortTurnId)
266
+ ctx.onAutoAbort?.(abortTurnId);
267
+ }
268
+ return;
269
+ }
270
+ if (outcome.kind === "adopt-active") {
271
+ // AC-FINAL-001: for a still-current submission, consume durable stop
272
+ // intent before applyAdoptActiveOwnership → releaseSubmitOwnership
273
+ // clears stopRequested.
274
+ // Stale late adopt-active (newer submit already advanced generation)
275
+ // must not write old turnId into abortPostedTurnIds or fire onAutoAbort.
276
+ const abortTurnId = current
277
+ ? consumeStopIntentForAbort(bag, outcome.activeTurn.turnId)
278
+ : null;
279
+ applyAdoptActiveOwnership(bag, {
280
+ activeTurnId: outcome.activeTurn.turnId,
281
+ message: outcome.message,
282
+ assistantId: ctx.assistantId,
283
+ text: ctx.text,
284
+ images: ctx.images,
285
+ pending: ctx.pending,
286
+ current,
287
+ notice: ctx.notice,
288
+ ui: ctx.adoptUi,
289
+ });
290
+ if (abortTurnId)
291
+ ctx.onAutoAbort?.(abortTurnId);
292
+ return;
293
+ }
294
+ if (outcome.kind === "rejected") {
295
+ applyRejectedOwnership(bag, {
296
+ message: outcome.message,
297
+ assistantId: ctx.assistantId,
298
+ text: ctx.text,
299
+ images: ctx.images,
300
+ pending: ctx.pending,
301
+ current,
302
+ ui: ctx.rejectedUi,
303
+ });
304
+ clearStopRequested(bag);
305
+ return;
306
+ }
307
+ if (outcome.kind === "stale") {
308
+ ctx.pending.resolveOutcome(outcome);
309
+ releaseSubmitOwnership(bag, ctx.pending.clientRequestId);
310
+ clearStopRequested(bag);
311
+ return;
312
+ }
313
+ // ambiguous — leave pending/busy for reconcile; do not release ownership
314
+ // or forge idle. clientRequestId stays on pendingSubmission.
315
+ // Durable stopRequested (if set) survives until bind/accept/terminal.
316
+ ctx.pending.resolveOutcome(outcome);
317
+ }
318
+ /**
319
+ * Apply continuous /state reconcile decision for a still-owned pending submission.
320
+ * Same clientRequestId only — never mints a new id or re-POSTs Prompt.
321
+ */
322
+ export function applyPendingReconcileDecision(bag, decision, state, opts) {
323
+ const pending = bag.pendingSubmission;
324
+ if (!pending)
325
+ return "held";
326
+ if (decision.action === "bind-active") {
327
+ const alreadyBound = Boolean(pending.acceptedTurnId);
328
+ if (!alreadyBound) {
329
+ // US-BUSY-ACTIVE-BOUND: keep streaming/busy with same id.
330
+ pending.acceptedTurnId = decision.turnId;
331
+ bag.activeTurnId = decision.turnId;
332
+ bag.turnAssistantIds.set(decision.turnId, pending.assistantId);
333
+ bag.streaming = true;
334
+ bag.submitInFlight = false;
335
+ // Retain pendingSubmission until terminal/non-acceptance release.
336
+ }
337
+ // AC-R5-001: durable stopRequested survives ambiguous create; bind-active
338
+ // must fire exactly one real abort for the bound turnId.
339
+ const abortTurnId = consumeStopIntentForAbort(bag, decision.turnId);
340
+ if (abortTurnId)
341
+ opts?.onAutoAbort?.(abortTurnId);
342
+ return alreadyBound ? "held" : "bound";
343
+ }
344
+ if (decision.action === "release-terminal" ||
345
+ decision.action === "release-not-accepted") {
346
+ // Terminal / not-accepted release clears ownership even after bind-active
347
+ // set acceptedTurnId (pendingSubmission held until this release).
348
+ // Independent of React streaming projection (AC-R5-002).
349
+ if (decision.action === "release-terminal") {
350
+ bag.settledTurnIds.add(decision.turnId);
351
+ }
352
+ releaseSubmitOwnership(bag, pending.clientRequestId);
353
+ clearStopRequested(bag);
354
+ const stillBusy = isRuntimeBusyUnion(state);
355
+ if (!stillBusy) {
356
+ bag.streaming = false;
357
+ bag.activeTurnId = null;
358
+ bag.assistantId = null;
359
+ bag.stoppingTurnId = null;
360
+ opts?.markIdle?.();
361
+ }
362
+ return "released";
363
+ }
364
+ // hold: keep pending clientRequestId + busy; no applyRejected.
365
+ const activeTurnId = state?.activeTurn?.turnId ?? state?.activeTurnId ?? null;
366
+ if (activeTurnId)
367
+ bag.activeTurnId = activeTurnId;
368
+ return "held";
369
+ }
370
+ export const STOPPING_NOTICE = "正在停止…";
371
+ export const ABORT_PENDING_NOTICE = "正在停止…";
372
+ export const ABORT_INCOMPLETE_NOTICE = "停止请求未完成,正在与服务端对账…";
373
+ export const ABORT_FAILED_NOTICE = "停止请求失败,正在与服务端对账…";
374
+ /**
375
+ * Emit non-fatal stop-before-turnId notice. Never calls setError.
376
+ * Stop never disposes Session (no dispose in this module).
377
+ */
378
+ export function emitStoppingNotice(notice) {
379
+ notice.showNotice(STOPPING_NOTICE);
380
+ }
381
+ export function classifyAbortResponse(input) {
382
+ if (input.networkError) {
383
+ return { action: "keep-busy-notice", message: ABORT_FAILED_NOTICE };
384
+ }
385
+ const terminalState = input.body.turn?.state;
386
+ if (input.body.accepted === true &&
387
+ isTerminalTurnState(terminalState)) {
388
+ return { action: "terminal-idle", turnId: input.turnId };
389
+ }
390
+ if (input.body.error?.code === "TURN_ABORT_PENDING") {
391
+ return { action: "abort-pending-notice" };
392
+ }
393
+ if (input.body.error?.code === "CHAT_TURN_OWNER_LOST" ||
394
+ input.body.error?.code === "OWNER_LOST") {
395
+ return { action: "owner-lost-reconcile" };
396
+ }
397
+ if (!input.httpOk) {
398
+ return { action: "keep-busy-notice", message: ABORT_INCOMPLETE_NOTICE };
399
+ }
400
+ return { action: "noop" };
401
+ }
402
+ /**
403
+ * Apply abort decision. Notice paths use showNotice only — never setError.
404
+ * Session dispose is intentionally absent.
405
+ */
406
+ export function applyAbortDecision(bag, decision, notice, opts) {
407
+ const current = opts?.sessionStillCurrent !== false;
408
+ if (decision.action === "terminal-idle") {
409
+ if (!current)
410
+ return;
411
+ bag.settledTurnIds.add(decision.turnId);
412
+ bag.streaming = false;
413
+ bag.activeTurnId = null;
414
+ bag.assistantId = null;
415
+ bag.stoppingTurnId = null;
416
+ opts?.markIdle?.();
417
+ return;
418
+ }
419
+ if (decision.action === "abort-pending-notice") {
420
+ // Non-fatal; keep busy.
421
+ notice.showNotice(ABORT_PENDING_NOTICE);
422
+ return;
423
+ }
424
+ if (decision.action === "owner-lost-reconcile") {
425
+ if (!current)
426
+ return;
427
+ if (!opts?.runtimeBusy) {
428
+ if (bag.stoppingTurnId) {
429
+ bag.settledTurnIds.add(bag.stoppingTurnId);
430
+ }
431
+ bag.streaming = false;
432
+ bag.activeTurnId = null;
433
+ bag.assistantId = null;
434
+ bag.stoppingTurnId = null;
435
+ opts?.markIdle?.();
436
+ }
437
+ return;
438
+ }
439
+ if (decision.action === "keep-busy-notice") {
440
+ notice.showNotice(decision.message);
441
+ return;
442
+ }
443
+ }
444
+ /**
445
+ * Simulate the double-POST ambiguous → /state bind → terminal release path
446
+ * for executable ownership tests. Reuses one clientRequestId throughout.
447
+ */
448
+ export function simulateAmbiguousBindRelease(input) {
449
+ const assistantId = input.assistantId ?? "a-test";
450
+ const bag = createTurnOwnershipBag({
451
+ sessionId: input.sessionId,
452
+ sessionGeneration: 1,
453
+ });
454
+ const phases = [];
455
+ const pending = beginSubmitOwnership(bag, {
456
+ clientRequestId: input.clientRequestId,
457
+ sessionId: input.sessionId,
458
+ sessionGeneration: 1,
459
+ text: "hello",
460
+ images: [],
461
+ assistantId,
462
+ userMessageId: "u-test",
463
+ });
464
+ bag.streaming = true;
465
+ // After double-POST ambiguous: settle without release.
466
+ settleCreateTurnOutcome(bag, { kind: "ambiguous", message: "transport unclear" }, {
467
+ assistantId,
468
+ text: "hello",
469
+ images: [],
470
+ pending,
471
+ sessionId: input.sessionId,
472
+ notice: { showNotice: () => { }, setError: () => { } },
473
+ });
474
+ phases.push("pending-ambiguous");
475
+ // /state active bind with same clientRequestId.
476
+ const bound = applyPendingReconcileDecision(bag, { action: "bind-active", turnId: input.turnId }, {
477
+ activeTurn: {
478
+ turnId: input.turnId,
479
+ clientRequestId: input.clientRequestId,
480
+ state: "running",
481
+ },
482
+ });
483
+ if (bound !== "bound") {
484
+ throw new Error("expected bind-active");
485
+ }
486
+ phases.push("bound-active");
487
+ // Terminal release frees both ownership refs.
488
+ const released = applyPendingReconcileDecision(bag, {
489
+ action: "release-terminal",
490
+ turnId: input.turnId,
491
+ state: "settled",
492
+ }, {
493
+ latestTurn: {
494
+ turnId: input.turnId,
495
+ clientRequestId: input.clientRequestId,
496
+ state: "settled",
497
+ },
498
+ runtime: {
499
+ isStreaming: false,
500
+ isPromptRunning: false,
501
+ isCompacting: false,
502
+ isBashRunning: false,
503
+ },
504
+ });
505
+ if (released !== "released") {
506
+ throw new Error("expected release-terminal");
507
+ }
508
+ phases.push("released-terminal");
509
+ return { bag, clientRequestId: input.clientRequestId, phases };
510
+ }
511
+ /** True when production defaults do not activate a Mock handler. */
512
+ export function isMockActivationOffByDefault() {
513
+ // No production Mock switch exists in this controller; real paths only.
514
+ return true;
515
+ }
516
+ /**
517
+ * Production-path Stop: set durable stopRequested synchronously before any
518
+ * await. If turnId is already known, coalesce to one POST /abort. If not,
519
+ * intent survives until accepted/adopt/bind-active yields a turnId.
520
+ * Never remints clientRequestId; never disposes Session.
521
+ */
522
+ export function requestStopOnOwnership(bag, hooks) {
523
+ markStopRequested(bag);
524
+ const turnId = bag.activeTurnId ??
525
+ bag.pendingSubmission?.acceptedTurnId ??
526
+ null;
527
+ if (turnId) {
528
+ const abortTurnId = consumeStopIntentForAbort(bag, turnId);
529
+ if (abortTurnId) {
530
+ hooks.notice && emitStoppingNotice(hooks.notice);
531
+ void hooks.postAbort(abortTurnId);
532
+ return { abortedTurnId: abortTurnId, stopRequested: true };
533
+ }
534
+ return { abortedTurnId: null, stopRequested: true };
535
+ }
536
+ // No turnId yet (create in flight / ambiguous) — durable intent only.
537
+ hooks.notice && emitStoppingNotice(hooks.notice);
538
+ return { abortedTurnId: null, stopRequested: true };
539
+ }
540
+ /**
541
+ * After create settles or /state bind, honor durable stopRequested with at
542
+ * most one abort per turnId. Same clientRequestId; no second create-turn POST.
543
+ */
544
+ export function honorStopIntentAfterBind(bag, turnId, hooks) {
545
+ const abortTurnId = consumeStopIntentForAbort(bag, turnId);
546
+ if (!abortTurnId)
547
+ return null;
548
+ void hooks.postAbort(abortTurnId);
549
+ return abortTurnId;
550
+ }
551
+ /**
552
+ * Drive bind-active then terminal (or reverse order) against the production
553
+ * ownership helpers. Proves lease release is independent of React streaming.
554
+ */
555
+ export function runBindAndTerminalReleaseOrders(input) {
556
+ const bag = createTurnOwnershipBag({
557
+ sessionId: input.sessionId,
558
+ sessionGeneration: 1,
559
+ });
560
+ const pending = beginSubmitOwnership(bag, {
561
+ clientRequestId: input.clientRequestId,
562
+ sessionId: input.sessionId,
563
+ sessionGeneration: 1,
564
+ text: "hello",
565
+ images: [],
566
+ assistantId: "a-orch",
567
+ userMessageId: "u-orch",
568
+ });
569
+ bag.streaming = true;
570
+ settleCreateTurnOutcome(bag, { kind: "ambiguous", message: "transport unclear" }, {
571
+ assistantId: "a-orch",
572
+ text: "hello",
573
+ images: [],
574
+ pending,
575
+ sessionId: input.sessionId,
576
+ notice: { showNotice: () => { }, setError: () => { } },
577
+ });
578
+ const bindState = {
579
+ activeTurn: {
580
+ turnId: input.turnId,
581
+ clientRequestId: input.clientRequestId,
582
+ state: "running",
583
+ },
584
+ };
585
+ const terminalState = {
586
+ latestTurn: {
587
+ turnId: input.turnId,
588
+ clientRequestId: input.clientRequestId,
589
+ state: "settled",
590
+ },
591
+ runtime: {
592
+ isStreaming: false,
593
+ isPromptRunning: false,
594
+ isCompacting: false,
595
+ isBashRunning: false,
596
+ },
597
+ };
598
+ if (input.order === "bind-first") {
599
+ applyPendingReconcileDecision(bag, { action: "bind-active", turnId: input.turnId }, bindState);
600
+ applyPendingReconcileDecision(bag, {
601
+ action: "release-terminal",
602
+ turnId: input.turnId,
603
+ state: "settled",
604
+ }, terminalState);
605
+ }
606
+ else if (input.order === "terminal-first") {
607
+ // Terminal SSE evidence before /state bind: release by clientRequestId.
608
+ releaseOwnershipOnTerminalEvidence(bag, {
609
+ clientRequestId: input.clientRequestId,
610
+ turnId: input.turnId,
611
+ });
612
+ }
613
+ else {
614
+ // React streaming already false (SSE projected idle) must not skip lease.
615
+ bag.streaming = false;
616
+ releaseOwnershipOnTerminalEvidence(bag, {
617
+ clientRequestId: input.clientRequestId,
618
+ turnId: input.turnId,
619
+ });
620
+ }
621
+ return {
622
+ bag,
623
+ clientRequestId: input.clientRequestId,
624
+ busyAfter: isSubmitOwnershipBusy(bag),
625
+ };
626
+ }
627
+ /**
628
+ * Production orchestration: Stop during ambiguous create → bind-active → one abort.
629
+ * Reuses original clientRequestId; no second create-turn POST; no Session dispose.
630
+ */
631
+ export function runStopDuringAmbiguousThenBind(input) {
632
+ const abortCalls = [];
633
+ const bag = createTurnOwnershipBag({
634
+ sessionId: input.sessionId,
635
+ sessionGeneration: 1,
636
+ });
637
+ const pending = beginSubmitOwnership(bag, {
638
+ clientRequestId: input.clientRequestId,
639
+ sessionId: input.sessionId,
640
+ sessionGeneration: 1,
641
+ text: "stop-race",
642
+ images: [],
643
+ assistantId: "a-stop",
644
+ userMessageId: "u-stop",
645
+ });
646
+ bag.streaming = true;
647
+ // create-turn still ambiguous / unresolved — Stop sets durable intent.
648
+ const hooks = {
649
+ postAbort: (turnId) => {
650
+ abortCalls.push(turnId);
651
+ input.postAbort(turnId);
652
+ },
653
+ notice: { showNotice: () => { }, setError: () => { } },
654
+ };
655
+ requestStopOnOwnership(bag, hooks);
656
+ // Ambiguous settle leaves pending + stopRequested.
657
+ settleCreateTurnOutcome(bag, { kind: "ambiguous", message: "unclear" }, {
658
+ assistantId: "a-stop",
659
+ text: "stop-race",
660
+ images: [],
661
+ pending,
662
+ sessionId: input.sessionId,
663
+ notice: hooks.notice,
664
+ onAutoAbort: (turnId) => void hooks.postAbort(turnId),
665
+ });
666
+ const stopRequestedAfterAmbiguous = bag.stopRequested;
667
+ const abortCallsAfterAmbiguous = abortCalls.length;
668
+ // /state bind-active → exactly one abort; same clientRequestId; no remint.
669
+ applyPendingReconcileDecision(bag, { action: "bind-active", turnId: input.turnId }, {
670
+ activeTurn: {
671
+ turnId: input.turnId,
672
+ clientRequestId: input.clientRequestId,
673
+ state: "running",
674
+ },
675
+ }, {
676
+ onAutoAbort: (turnId) => void hooks.postAbort(turnId),
677
+ });
678
+ // Repeated Stop / bind-time auto-abort coalesce to one.
679
+ requestStopOnOwnership(bag, hooks);
680
+ honorStopIntentAfterBind(bag, input.turnId, hooks);
681
+ return {
682
+ bag,
683
+ clientRequestId: input.clientRequestId,
684
+ abortCalls,
685
+ // Orchestration never issues a second create-turn POST.
686
+ createTurnPosts: 0,
687
+ stopRequestedAfterAmbiguous,
688
+ abortCallsAfterAmbiguous,
689
+ };
690
+ }