@opengeni/react 1.0.1 → 2.2.0-canary.0

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 (174) hide show
  1. package/README.md +33 -10
  2. package/dist/artifacts-spreadsheet.js +1 -1
  3. package/dist/artifacts.js +1 -1
  4. package/dist/browser-viewer-GXUICIAG.js +14 -0
  5. package/dist/{chunk-R3AZOAB5.js → chunk-4MDLEHXL.js} +9 -115
  6. package/dist/chunk-4MDLEHXL.js.map +1 -0
  7. package/dist/{chunk-Y7OEDUJ5.js → chunk-5RZN3DD5.js} +1014 -420
  8. package/dist/chunk-5RZN3DD5.js.map +1 -0
  9. package/dist/chunk-5YK2ZDIG.js +116 -0
  10. package/dist/chunk-5YK2ZDIG.js.map +1 -0
  11. package/dist/chunk-H5YGYNXS.js +329 -0
  12. package/dist/chunk-H5YGYNXS.js.map +1 -0
  13. package/dist/{chunk-7WCLAEFV.js → chunk-ICA6R6TW.js} +2 -2
  14. package/dist/{chunk-7WCLAEFV.js.map → chunk-ICA6R6TW.js.map} +1 -1
  15. package/dist/{chunk-IGC6A2ZI.js → chunk-JOAVA2TW.js} +589 -217
  16. package/dist/chunk-JOAVA2TW.js.map +1 -0
  17. package/dist/{chunk-5UQPNLIO.js → chunk-LTCHJ4MS.js} +9 -9
  18. package/dist/chunk-LTCHJ4MS.js.map +1 -0
  19. package/dist/{chunk-C5ZPSSS5.js → chunk-NEXLLTP4.js} +2 -1
  20. package/dist/{chunk-C5ZPSSS5.js.map → chunk-NEXLLTP4.js.map} +1 -1
  21. package/dist/chunk-QGBJ6GHG.js +2300 -0
  22. package/dist/chunk-QGBJ6GHG.js.map +1 -0
  23. package/dist/{chunk-B6WPZSTT.js → chunk-UOLJWT5Y.js} +78 -16
  24. package/dist/chunk-UOLJWT5Y.js.map +1 -0
  25. package/dist/{chunk-MKPIAGVL.js → chunk-W6NFJXSA.js} +8 -19
  26. package/dist/chunk-W6NFJXSA.js.map +1 -0
  27. package/dist/{chunk-XBZWOM2K.js → chunk-X4A66EWZ.js} +8 -3
  28. package/dist/chunk-X4A66EWZ.js.map +1 -0
  29. package/dist/chunk-X4JAFD5R.js +3907 -0
  30. package/dist/chunk-X4JAFD5R.js.map +1 -0
  31. package/dist/{chunk-6KLX2RH5.js → chunk-XSA2QAKP.js} +334 -19
  32. package/dist/chunk-XSA2QAKP.js.map +1 -0
  33. package/dist/{chunk-HA46CYQK.js → chunk-XXWH2JLC.js} +792 -206
  34. package/dist/chunk-XXWH2JLC.js.map +1 -0
  35. package/dist/client.d.ts +2 -2
  36. package/dist/components/browser-viewer.d.ts +7 -1
  37. package/dist/components/computer-viewer.d.ts +4 -1
  38. package/dist/components/enrollment-consent.d.ts +6 -3
  39. package/dist/components/file-browser.d.ts +7 -1
  40. package/dist/components/human-input-form.d.ts +1 -1
  41. package/dist/components/human-input-surface.d.ts +1 -1
  42. package/dist/components/machine-card.d.ts +4 -1
  43. package/dist/components/machines/machine-detail.d.ts +4 -1
  44. package/dist/components/machines/machine-operation-policy.d.ts +11 -0
  45. package/dist/components/machines-dashboard.d.ts +3 -1
  46. package/dist/components/markdown.d.ts +20 -1
  47. package/dist/components/message-timeline.d.ts +1 -1
  48. package/dist/components/model-policy-picker.d.ts +2 -0
  49. package/dist/components/pierre-file.d.ts +1 -1
  50. package/dist/components/sandbox-files.d.ts +8 -2
  51. package/dist/components/sandbox-workspace.d.ts +33 -3
  52. package/dist/components/session-chrome.d.ts +15 -3
  53. package/dist/components/workspace-dock.d.ts +6 -2
  54. package/dist/composer.js +5 -5
  55. package/dist/computer-viewer-ZOP4Q7A7.js +16 -0
  56. package/dist/computer-viewer-ZOP4Q7A7.js.map +1 -0
  57. package/dist/hooks/channel-cache.d.ts +23 -0
  58. package/dist/hooks/shared-machines-list.d.ts +14 -0
  59. package/dist/hooks/use-attached-browsers.d.ts +2 -1
  60. package/dist/hooks/use-channels.d.ts +1 -0
  61. package/dist/hooks/use-composer.d.ts +37 -9
  62. package/dist/hooks/use-machine-chip.d.ts +3 -3
  63. package/dist/hooks/use-machines.d.ts +19 -1
  64. package/dist/hooks/use-sandbox-files.d.ts +1 -0
  65. package/dist/hooks/use-session-background-commands.d.ts +14 -0
  66. package/dist/hooks/use-turn-queue.d.ts +12 -0
  67. package/dist/hooks/use-workspace-sessions.d.ts +1 -0
  68. package/dist/index.d.ts +7 -5
  69. package/dist/index.js +1096 -623
  70. package/dist/index.js.map +1 -1
  71. package/dist/interaction.js +29 -181
  72. package/dist/interaction.js.map +1 -1
  73. package/dist/lib/interaction-errors.d.ts +3 -0
  74. package/dist/machines.d.ts +4 -2
  75. package/dist/machines.js +10 -2
  76. package/dist/model-policy.d.ts +1 -1
  77. package/dist/model-policy.js +1 -1
  78. package/dist/platform-activity-row-BJHUHXAT.js +169 -0
  79. package/dist/platform-activity-row-BJHUHXAT.js.map +1 -0
  80. package/dist/{queue-surface-implementation-2KVJTYXH.js → queue-surface-implementation-BSKUWHRI.js} +76 -16
  81. package/dist/queue-surface-implementation-BSKUWHRI.js.map +1 -0
  82. package/dist/realtime/realtime-control.d.ts +6 -3
  83. package/dist/realtime.js +36 -14
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/session-ui.d.ts +1 -1
  86. package/dist/session-ui.js +6 -2
  87. package/dist/session.d.ts +2 -2
  88. package/dist/session.js +4 -4
  89. package/dist/timeline/index.d.ts +1 -1
  90. package/dist/timeline/platform-activity-row.d.ts +15 -0
  91. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  92. package/dist/timeline/shared.d.ts +4 -2
  93. package/dist/timeline/types.d.ts +48 -5
  94. package/dist/types/machines.d.ts +2 -2
  95. package/package.json +3 -3
  96. package/src/client.ts +12 -1
  97. package/src/components/artifacts/editable-spreadsheet.tsx +1 -1
  98. package/src/components/browser-viewer.tsx +361 -58
  99. package/src/components/chat-composer.tsx +1 -1
  100. package/src/components/composer.tsx +6 -6
  101. package/src/components/computer-viewer.tsx +368 -115
  102. package/src/components/enrollment-consent.tsx +64 -6
  103. package/src/components/file-browser.tsx +117 -2
  104. package/src/components/human-input-form.tsx +91 -55
  105. package/src/components/human-input-surface.tsx +1 -1
  106. package/src/components/machine-card.tsx +111 -0
  107. package/src/components/machine-input-display.ts +15 -0
  108. package/src/components/machines/machine-detail.tsx +47 -0
  109. package/src/components/machines/machine-operation-policy.tsx +179 -0
  110. package/src/components/machines-dashboard.tsx +6 -0
  111. package/src/components/markdown.tsx +174 -20
  112. package/src/components/message-timeline.tsx +162 -17
  113. package/src/components/model-policy-picker.tsx +9 -17
  114. package/src/components/pierre-file.tsx +1 -1
  115. package/src/components/queue-surface-implementation.tsx +82 -16
  116. package/src/components/sandbox-files.tsx +135 -19
  117. package/src/components/sandbox-workspace.tsx +204 -92
  118. package/src/components/session-chrome.tsx +415 -208
  119. package/src/components/session-status.tsx +1 -1
  120. package/src/components/user-message-body.tsx +168 -48
  121. package/src/components/workspace-dock.tsx +141 -175
  122. package/src/hooks/channel-cache.ts +104 -0
  123. package/src/hooks/shared-machines-list.ts +239 -0
  124. package/src/hooks/use-attached-browsers.ts +5 -1
  125. package/src/hooks/use-browser-frame-stream.ts +6 -1
  126. package/src/hooks/use-browser-session.ts +6 -4
  127. package/src/hooks/use-browser-sessions.ts +7 -1
  128. package/src/hooks/use-channels.ts +32 -10
  129. package/src/hooks/use-composer.ts +760 -291
  130. package/src/hooks/use-computer-frame-stream.ts +14 -9
  131. package/src/hooks/use-computer-session.ts +16 -14
  132. package/src/hooks/use-computer-sessions.ts +8 -1
  133. package/src/hooks/use-last-started-turn-policy.ts +1 -1
  134. package/src/hooks/use-machine-chip.ts +23 -6
  135. package/src/hooks/use-machines.ts +104 -6
  136. package/src/hooks/use-sandbox-files.ts +195 -140
  137. package/src/hooks/use-sandbox-git.ts +58 -29
  138. package/src/hooks/use-session-background-commands.ts +56 -0
  139. package/src/hooks/use-session-capabilities.ts +18 -0
  140. package/src/hooks/use-turn-queue.ts +111 -12
  141. package/src/hooks/use-workspace-sessions.ts +5 -1
  142. package/src/index.ts +26 -4
  143. package/src/lib/interaction-errors.ts +18 -0
  144. package/src/machines.ts +9 -1
  145. package/src/model-policy.ts +10 -2
  146. package/src/realtime/realtime-control.tsx +53 -17
  147. package/src/session-context.ts +1 -0
  148. package/src/session-ui.ts +6 -1
  149. package/src/session.ts +9 -1
  150. package/src/timeline/activity-rail.tsx +20 -75
  151. package/src/timeline/index.ts +4 -0
  152. package/src/timeline/platform-activity-row.tsx +201 -0
  153. package/src/timeline/projection.ts +476 -23
  154. package/src/timeline/screenshot-lightbox.tsx +48 -14
  155. package/src/timeline/shared.tsx +8 -2
  156. package/src/timeline/tool-renderers.tsx +104 -6
  157. package/src/timeline/turn-summary.tsx +6 -1
  158. package/src/timeline/types.ts +75 -2
  159. package/src/types/machines.ts +10 -0
  160. package/styles/compiled.css +115 -104
  161. package/dist/chunk-5UQPNLIO.js.map +0 -1
  162. package/dist/chunk-6KLX2RH5.js.map +0 -1
  163. package/dist/chunk-B6WPZSTT.js.map +0 -1
  164. package/dist/chunk-HA46CYQK.js.map +0 -1
  165. package/dist/chunk-IGC6A2ZI.js.map +0 -1
  166. package/dist/chunk-LLOLUHVG.js +0 -5937
  167. package/dist/chunk-LLOLUHVG.js.map +0 -1
  168. package/dist/chunk-MKPIAGVL.js.map +0 -1
  169. package/dist/chunk-R3AZOAB5.js.map +0 -1
  170. package/dist/chunk-XBZWOM2K.js.map +0 -1
  171. package/dist/chunk-Y7OEDUJ5.js.map +0 -1
  172. package/dist/interactive-workbench-viewers-TOHBXJEE.js +0 -15
  173. package/dist/queue-surface-implementation-2KVJTYXH.js.map +0 -1
  174. /package/dist/{interactive-workbench-viewers-TOHBXJEE.js.map → browser-viewer-GXUICIAG.js.map} +0 -0
@@ -4,17 +4,29 @@ import {
4
4
  type DraftTimelineAnnotation,
5
5
  type EffectiveControlResumeOption,
6
6
  type EffectiveSessionControl,
7
+ type LatencyMode,
7
8
  type OpenGeniApiError,
9
+ type ReasoningEffort,
8
10
  type ResourceRef,
9
11
  type SaveComposerDraftRequest,
10
12
  type SendMessageInput,
11
13
  type SessionEvent,
14
+ type SubmitComposerDraftResponse,
12
15
  } from "@opengeni/sdk";
13
16
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
14
17
  import { useEmbeddedSession, type EmbeddedSessionClientOverride } from "../session-context";
15
18
  import { useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
16
19
 
17
- export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId" | "annotations">;
20
+ export type ComposerPolicy = {
21
+ model: string;
22
+ reasoningEffort: ReasoningEffort;
23
+ latencyMode: LatencyMode;
24
+ };
25
+
26
+ export type ComposerSendExtras = Omit<
27
+ SendMessageInput,
28
+ "text" | "clientEventId" | "annotations" | "model" | "reasoningEffort" | "latencyMode"
29
+ >;
18
30
 
19
31
  export type UseComposerOptions = EmbeddedSessionClientOverride &
20
32
  SessionEventFeedOptions & {
@@ -22,10 +34,13 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
22
34
  onSubmitted?: ((text: string, input: SendMessageInput) => void) | undefined;
23
35
  /** Called with the exact accepted wire input after a successful send. */
24
36
  onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
37
+ /** Called with the exact wire input after a delivery failure. */
38
+ onDeliveryError?:
39
+ | ((error: Error, input: SendMessageInput, delivery: "send" | "steer") => void)
40
+ | undefined;
25
41
  /**
26
- * Extra message fields (resources, tools, model, reasoningEffort, latencyMode) merged
27
- * into every send. A function is evaluated at send time so it can read the
28
- * surrounding UI state (attachment pickers, model selectors, ...).
42
+ * Non-policy message fields merged into every send. Durable policy belongs
43
+ * to this composer draft; attachments and credentials may remain host-owned.
29
44
  */
30
45
  sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
31
46
  /**
@@ -36,16 +51,19 @@ export type UseComposerOptions = EmbeddedSessionClientOverride &
36
51
  sendBlocked?: (() => boolean) | undefined;
37
52
  /** Latest server-derived workstream control; bound into Send/Steer OCC. */
38
53
  effectiveControl?: EffectiveSessionControl | null | undefined;
39
- /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
40
- onDraftApplied?: ((draft: ComposerDraft) => void) | undefined;
54
+ /** Immediate user-visible destination for an ordinary Send before admission replies. */
55
+ sendDestination?: (() => "chat" | "queue") | undefined;
41
56
  /** Disable remote composer-draft reads and writes for embedded hosts. */
42
57
  draftPersistence?: "durable" | "disabled" | undefined;
58
+ /** Required explicit authority when durable draft persistence is disabled. */
59
+ initialPolicy?: ComposerPolicy | undefined;
43
60
  };
44
61
 
45
62
  type ComposerDraftShadow = {
46
63
  text: string;
47
64
  resources: ResourceRef[];
48
65
  annotations: DraftTimelineAnnotation[];
66
+ policy?: ComposerPolicy | undefined;
49
67
  };
50
68
 
51
69
  type PendingComposerOperation = {
@@ -68,11 +86,16 @@ type StoredPendingComposerOperation = Omit<PendingComposerOperation, "input" | "
68
86
 
69
87
  export type ComposerOptimisticMessage = {
70
88
  clientEventId: string;
89
+ delivery: "send" | "steer";
90
+ destination: "chat" | "queue";
71
91
  text: string;
72
92
  annotations: DraftTimelineAnnotation[];
73
93
  resources: ResourceRef[];
74
94
  occurredAt: string;
75
95
  state: "sending" | "queued" | "failed";
96
+ turnId?: string | null | undefined;
97
+ triggerEventId?: string | null | undefined;
98
+ appliedQueueVersion?: number | null | undefined;
76
99
  error?: string | undefined;
77
100
  outcomeUnknown?: boolean | undefined;
78
101
  };
@@ -151,6 +174,8 @@ function restoreOptimisticSendOperations(key: string | null): OptimisticSendOper
151
174
  }
152
175
  return stored.map(({ hasMcpCredentialUpdates, ...operation }) => ({
153
176
  ...operation,
177
+ delivery: "send",
178
+ destination: operation.destination === "queue" ? "queue" : "chat",
154
179
  newerShadow: operation.newerShadow ?? { text: "", resources: [], annotations: [] },
155
180
  state:
156
181
  operation.state === "sending" || operation.state === "queued" ? "sending" : operation.state,
@@ -183,6 +208,15 @@ function rememberOptimisticSendOperations(
183
208
  hasMcpCredentialUpdates: input.mcpCredentialUpdates !== undefined,
184
209
  } satisfies StoredOptimisticSendOperation;
185
210
  });
211
+ if (stored.length === 0) {
212
+ optimisticSendOperations.delete(key);
213
+ try {
214
+ pendingComposerStorage()?.removeItem(optimisticSendStorageKey(key));
215
+ } catch {
216
+ // Best effort; the in-memory entry is already gone.
217
+ }
218
+ return;
219
+ }
186
220
  optimisticSendOperations.set(key, stored);
187
221
  try {
188
222
  pendingComposerStorage()?.setItem(optimisticSendStorageKey(key), JSON.stringify(stored));
@@ -344,6 +378,7 @@ function rememberPendingComposerOperation(
344
378
  text: operation.newerShadow.text,
345
379
  resources: [...operation.newerShadow.resources],
346
380
  annotations: cloneAnnotations(operation.newerShadow.annotations),
381
+ ...(operation.newerShadow.policy ? { policy: { ...operation.newerShadow.policy } } : {}),
347
382
  },
348
383
  } satisfies StoredPendingComposerOperation;
349
384
  pendingComposerOperations.set(key, stored);
@@ -412,11 +447,18 @@ export type ComposerState = {
412
447
  resume: (reason?: string) => Promise<void>;
413
448
  resumeScope: (option: EffectiveControlResumeOption) => Promise<void>;
414
449
  resuming: boolean;
450
+ /** Mutation-confirmed control while the streamed queue projection catches up. */
451
+ effectiveControl?: EffectiveSessionControl | null | undefined;
415
452
  draft: ComposerDraft | null;
416
453
  draftRevision: number;
417
454
  draftLoading: boolean;
418
455
  draftSaving: boolean;
419
456
  draftConflict: Error | null;
457
+ /** Exact policy owned by this actor/session composer. Null while hydrating. */
458
+ policy?: ComposerPolicy | null | undefined;
459
+ setModel?: ((model: string) => void) | undefined;
460
+ setReasoningEffort?: ((effort: ReasoningEffort) => void) | undefined;
461
+ setLatencyMode?: ((mode: LatencyMode) => void) | undefined;
420
462
  /** Whether this controller owns a durable server-side draft. */
421
463
  draftPersistence?: "durable" | "disabled" | undefined;
422
464
  /** Apply an atomic queue Edit checkout without a second read. */
@@ -429,19 +471,23 @@ export type ComposerState = {
429
471
  clearError: () => void;
430
472
  };
431
473
 
474
+ export type ComposerControllerState = ComposerState & {
475
+ policy: ComposerPolicy | null;
476
+ setModel: (model: string) => void;
477
+ setReasoningEffort: (effort: ReasoningEffort) => void;
478
+ setLatencyMode: (mode: LatencyMode) => void;
479
+ };
480
+
432
481
  export type ComposerSteeringState = {
433
- phase: "submitting" | "accepted";
482
+ phase: "submitting" | "accepted" | "failed";
434
483
  text: string;
435
484
  clientEventId: string | null;
436
485
  triggerEventId: string | null;
437
486
  turnId: string | null;
438
487
  /** True only when the accepted Steer durably interrupted a live attempt. */
439
488
  stoppingPreviousAttempt?: boolean | undefined;
440
- };
441
-
442
- type ComposerControlStoppingState = {
443
- controlVersion: number;
444
- controlEtag: string;
489
+ error?: string | undefined;
490
+ outcomeUnknown?: boolean | undefined;
445
491
  };
446
492
 
447
493
  const STEERING_SETTLEMENT_EVENT_TYPES = new Set([
@@ -468,6 +514,32 @@ function steeringAcceptedEvent(
468
514
  );
469
515
  }
470
516
 
517
+ function promptTurnIdForTrigger(
518
+ triggerEventId: string | null | undefined,
519
+ events: readonly SessionEvent[],
520
+ ): string | null {
521
+ if (!triggerEventId) return null;
522
+ const queued = events.find((event) => {
523
+ if (event.type !== "turn.queued") return false;
524
+ const payload = event.payload;
525
+ return (
526
+ typeof payload === "object" &&
527
+ payload !== null &&
528
+ "triggerEventId" in payload &&
529
+ payload.triggerEventId === triggerEventId
530
+ );
531
+ });
532
+ if (!queued) return null;
533
+ if (queued.turnId) return queued.turnId;
534
+ const payload = queued.payload;
535
+ return typeof payload === "object" &&
536
+ payload !== null &&
537
+ "turnId" in payload &&
538
+ typeof payload.turnId === "string"
539
+ ? payload.turnId
540
+ : null;
541
+ }
542
+
471
543
  function steeringSettledByEvents(
472
544
  steering: ComposerSteeringState,
473
545
  events: readonly SessionEvent[],
@@ -489,6 +561,63 @@ function steeringSettledByEvents(
489
561
  });
490
562
  }
491
563
 
564
+ function reconcileOptimisticSendFromEvents(
565
+ operation: OptimisticSendOperation,
566
+ events: readonly SessionEvent[],
567
+ ): { operation: OptimisticSendOperation; admitted: boolean; settled: boolean } {
568
+ const accepted = events.find(
569
+ (event) => event.type === "user.message" && event.clientEventId === operation.clientEventId,
570
+ );
571
+ const triggerEventId = operation.triggerEventId ?? accepted?.id ?? null;
572
+ const turnId = operation.turnId ?? promptTurnIdForTrigger(triggerEventId, events);
573
+ const needsAdmissionUpdate =
574
+ accepted !== undefined &&
575
+ (operation.state !== "queued" ||
576
+ operation.triggerEventId !== triggerEventId ||
577
+ operation.turnId !== turnId ||
578
+ operation.error !== undefined ||
579
+ operation.outcomeUnknown !== false);
580
+ const reconciled: OptimisticSendOperation = needsAdmissionUpdate
581
+ ? {
582
+ ...operation,
583
+ state: "queued",
584
+ triggerEventId,
585
+ turnId,
586
+ error: undefined,
587
+ outcomeUnknown: false,
588
+ }
589
+ : operation;
590
+ const withdrawnFromQueue = events.some((event) => {
591
+ if (event.type !== "session.queue.changed" || !turnId) return false;
592
+ const payload = event.payload;
593
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return false;
594
+ const record = payload as Record<string, unknown>;
595
+ return (
596
+ record.turnId === turnId && (record.operation === "edit" || record.operation === "delete")
597
+ );
598
+ });
599
+ return {
600
+ operation: reconciled,
601
+ admitted: accepted !== undefined,
602
+ // Edit/Delete atomically withdraw the queued turn. Keeping its optimistic
603
+ // send alive after that point lets its old `newerShadow` become the newest
604
+ // composer draft again when a later turn settles, duplicating submitted
605
+ // text. The queue mutation is the durable terminal fact for this send.
606
+ settled:
607
+ withdrawnFromQueue ||
608
+ steeringSettledByEvents(
609
+ {
610
+ phase: "accepted",
611
+ text: reconciled.text,
612
+ clientEventId: reconciled.clientEventId,
613
+ triggerEventId: reconciled.triggerEventId ?? null,
614
+ turnId: reconciled.turnId ?? null,
615
+ },
616
+ events,
617
+ ),
618
+ };
619
+ }
620
+
492
621
  /**
493
622
  * Draft + send + Pause/Resume state for the chat composer — the only
494
623
  * human-to-agent input surface. The draft survives a failed send (nothing is
@@ -498,9 +627,12 @@ function steeringSettledByEvents(
498
627
  export function useComposer(
499
628
  sessionId: string | null | undefined,
500
629
  options: UseComposerOptions = {},
501
- ): ComposerState {
630
+ ): ComposerControllerState {
502
631
  const { client, workspaceId, registerSessionReconciler } = useEmbeddedSession(options);
503
632
  const durableDrafts = options.draftPersistence !== "disabled";
633
+ if (!durableDrafts && !options.initialPolicy) {
634
+ throw new Error("useComposer requires initialPolicy when draft persistence is disabled");
635
+ }
504
636
  const targetKey = `${workspaceId}\u0000${sessionId ?? ""}\u0000${durableDrafts ? "durable" : "disabled"}`;
505
637
  const pendingOperationKey = pendingComposerOperationKey(workspaceId, sessionId);
506
638
  const initialPendingOperation = restorePendingComposerOperation(pendingOperationKey);
@@ -521,22 +653,27 @@ export function useComposer(
521
653
  const [steering, setSteering] = useState<ComposerSteeringState | null>(() =>
522
654
  initialPendingOperation?.delivery === "steer"
523
655
  ? {
524
- phase: "submitting",
656
+ phase: "failed",
525
657
  text: initialPendingOperation.input.text,
526
658
  clientEventId: initialPendingOperation.input.clientEventId ?? null,
527
659
  triggerEventId: null,
528
660
  turnId: null,
661
+ error: "Not confirmed. Retry to check the same direction change.",
662
+ outcomeUnknown: true,
529
663
  }
530
664
  : null,
531
665
  );
532
666
  const [pausing, setPausing] = useState(false);
533
- const [controlStopping, setControlStopping] = useState<ComposerControlStoppingState | null>(null);
667
+ const [acceptedControl, setAcceptedControl] = useState<EffectiveSessionControl | null>(null);
534
668
  const [resuming, setResuming] = useState(false);
535
669
  const [error, setError] = useState<Error | null>(null);
536
670
  const [draft, setDraft] = useState<ComposerDraft | null>(null);
537
671
  const [draftLoading, setDraftLoading] = useState(Boolean(sessionId) && durableDrafts);
538
672
  const [draftSaving, setDraftSaving] = useState(false);
539
673
  const [draftConflict, setDraftConflict] = useState<Error | null>(null);
674
+ const [policy, setPolicy] = useState<ComposerPolicy | null>(
675
+ () => initialShadow?.policy ?? options.initialPolicy ?? null,
676
+ );
540
677
  const [restoredResources, setRestoredResources] = useState<ResourceRef[]>(
541
678
  () => initialShadow?.resources ?? [],
542
679
  );
@@ -546,11 +683,15 @@ export function useComposer(
546
683
  const optimisticCallbackIdsRef = useRef(new Set<string>());
547
684
  const steeringSettlementEventsRef = useRef<SessionEvent[]>([]);
548
685
  const steeringRef = useRef(steering);
686
+ const steeringOccurredAtRef = useRef(new Date().toISOString());
549
687
  const pendingClientEventId = useRef<string | null>(
550
688
  initialPendingOperation?.input.clientEventId ?? null,
551
689
  );
552
690
  const valueRef = useRef(initialShadow?.text ?? "");
553
691
  const annotationsRef = useRef<DraftTimelineAnnotation[]>(initialShadow?.annotations ?? []);
692
+ const policyRef = useRef<ComposerPolicy | null>(
693
+ initialShadow?.policy ?? options.initialPolicy ?? null,
694
+ );
554
695
  const draftRef = useRef<ComposerDraft | null>(null);
555
696
  const restoredResourcesRef = useRef<ResourceRef[]>(initialShadow?.resources ?? []);
556
697
  const localEditRevision = useRef(initialShadow ? 1 : 0);
@@ -560,15 +701,8 @@ export function useComposer(
560
701
  const saveChain = useRef<Promise<void>>(Promise.resolve());
561
702
  const onSent = options.onSent;
562
703
  const onSubmitted = options.onSubmitted;
563
- const onDraftApplied = options.onDraftApplied;
564
- // Read through a ref so live session/policy projections can replace their
565
- // apply callback without invalidating the draft loader and re-running its
566
- // initial-load effect. Publish only committed callbacks: a suspended target
567
- // render must not retarget an in-flight read owned by the committed session.
568
- const onDraftAppliedRef = useRef(onDraftApplied);
569
- useLayoutEffect(() => {
570
- onDraftAppliedRef.current = onDraftApplied;
571
- }, [onDraftApplied]);
704
+ const onDeliveryErrorRef = useRef(options.onDeliveryError);
705
+ onDeliveryErrorRef.current = options.onDeliveryError;
572
706
  useLayoutEffect(() => {
573
707
  steeringRef.current = steering;
574
708
  }, [steering]);
@@ -576,8 +710,14 @@ export function useComposer(
576
710
  // callers passing inline functions) does not invalidate `send`.
577
711
  const sendExtrasRef = useRef(options.sendExtras);
578
712
  sendExtrasRef.current = options.sendExtras;
713
+ const sendDestinationRef = useRef(options.sendDestination);
714
+ sendDestinationRef.current = options.sendDestination;
579
715
  const sendBlockedRef = useRef(options.sendBlocked);
580
716
  sendBlockedRef.current = options.sendBlocked;
717
+ const effectiveControlRef = useRef<EffectiveSessionControl | null>(
718
+ acceptedControl ?? options.effectiveControl ?? null,
719
+ );
720
+ effectiveControlRef.current = acceptedControl ?? options.effectiveControl ?? null;
581
721
  const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
582
722
 
583
723
  // A composer is bound to one session: switching targets must not leak the
@@ -600,6 +740,7 @@ export function useComposer(
600
740
  localEditRevision.current = shadow ? 1 : 0;
601
741
  valueRef.current = shadow?.text ?? "";
602
742
  annotationsRef.current = shadow?.annotations ?? [];
743
+ policyRef.current = shadow?.policy ?? options.initialPolicy ?? null;
603
744
  draftRef.current = null;
604
745
  restoredResourcesRef.current = shadow?.resources ?? [];
605
746
  lastSavedSignature.current = null;
@@ -609,22 +750,25 @@ export function useComposer(
609
750
  setStateTargetKey(targetKey);
610
751
  setValue(shadow?.text ?? "");
611
752
  setAnnotations(shadow?.annotations ?? []);
753
+ setPolicy(policyRef.current);
612
754
  setAnnotationReviewTargetId(null);
613
755
  setSending(false);
614
756
  setOptimisticSends(restoredOptimisticSends);
615
757
  setSteering(
616
758
  pendingOperationRef.current?.delivery === "steer"
617
759
  ? {
618
- phase: "submitting",
760
+ phase: "failed",
619
761
  text: pendingOperationRef.current.input.text,
620
762
  clientEventId: pendingOperationRef.current.input.clientEventId ?? null,
621
763
  triggerEventId: null,
622
764
  turnId: null,
765
+ error: "Not confirmed. Retry to check the same direction change.",
766
+ outcomeUnknown: true,
623
767
  }
624
768
  : null,
625
769
  );
626
770
  setPausing(false);
627
- setControlStopping(null);
771
+ setAcceptedControl(null);
628
772
  setResuming(false);
629
773
  setError(null);
630
774
  setDraft(null);
@@ -632,7 +776,7 @@ export function useComposer(
632
776
  setDraftSaving(false);
633
777
  setDraftConflict(null);
634
778
  setRestoredResources(shadow?.resources ?? []);
635
- }, [durableDrafts, pendingOperationKey, sessionId, targetKey]);
779
+ }, [durableDrafts, options.initialPolicy, pendingOperationKey, sessionId, targetKey]);
636
780
 
637
781
  const setOptimisticDraftShadow = useCallback(
638
782
  (shadow: ComposerDraftShadow): void => {
@@ -643,6 +787,7 @@ export function useComposer(
643
787
  text: shadow.text,
644
788
  resources: [...shadow.resources],
645
789
  annotations: cloneAnnotations(shadow.annotations),
790
+ ...(shadow.policy ? { policy: { ...shadow.policy } } : {}),
646
791
  },
647
792
  }));
648
793
  optimisticSendsRef.current = next;
@@ -656,15 +801,18 @@ export function useComposer(
656
801
  (next: ComposerDraft): void => {
657
802
  if (targetKeyRef.current !== targetKey) return;
658
803
  if (!durableDrafts) {
804
+ const nextPolicy = policyFromDraft(next);
659
805
  localEditRevision.current += 1;
660
806
  valueRef.current = next.text;
661
807
  annotationsRef.current = next.annotations ?? [];
808
+ policyRef.current = nextPolicy;
662
809
  restoredResourcesRef.current = next.resources;
663
810
  draftRef.current = null;
664
811
  lastSavedSignature.current = null;
665
812
  setDraft(null);
666
813
  setValue(next.text);
667
814
  setAnnotations(next.annotations ?? []);
815
+ setPolicy(nextPolicy);
668
816
  setRestoredResources(next.resources);
669
817
  pendingOperationRef.current = updatePendingComposerShadow(
670
818
  pendingOperationKey,
@@ -676,6 +824,7 @@ export function useComposer(
676
824
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
677
825
  ),
678
826
  annotations: next.annotations ?? [],
827
+ policy: nextPolicy,
679
828
  },
680
829
  );
681
830
  setOptimisticDraftShadow({
@@ -685,12 +834,15 @@ export function useComposer(
685
834
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
686
835
  ),
687
836
  annotations: next.annotations ?? [],
837
+ policy: nextPolicy,
688
838
  });
689
839
  setDraftConflict(null);
690
840
  return;
691
841
  }
842
+ const nextPolicy = policyFromDraft(next);
692
843
  valueRef.current = next.text;
693
844
  annotationsRef.current = next.annotations ?? [];
845
+ policyRef.current = nextPolicy;
694
846
  draftRef.current = next;
695
847
  restoredResourcesRef.current = next.resources;
696
848
  lastSavedSignature.current = draftSignature(draftPayload(next));
@@ -698,6 +850,7 @@ export function useComposer(
698
850
  setDraft(next);
699
851
  setValue(next.text);
700
852
  setAnnotations(next.annotations ?? []);
853
+ setPolicy(nextPolicy);
701
854
  setRestoredResources(next.resources);
702
855
  pendingOperationRef.current = updatePendingComposerShadow(
703
856
  pendingOperationKey,
@@ -709,6 +862,7 @@ export function useComposer(
709
862
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
710
863
  ),
711
864
  annotations: next.annotations ?? [],
865
+ policy: nextPolicy,
712
866
  },
713
867
  );
714
868
  setOptimisticDraftShadow({
@@ -718,9 +872,9 @@ export function useComposer(
718
872
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
719
873
  ),
720
874
  annotations: next.annotations ?? [],
875
+ policy: nextPolicy,
721
876
  });
722
877
  setDraftConflict(null);
723
- onDraftAppliedRef.current?.(next);
724
878
  },
725
879
  [durableDrafts, pendingOperationKey, setOptimisticDraftShadow, targetKey],
726
880
  );
@@ -736,17 +890,20 @@ export function useComposer(
736
890
  const readTicket = ++draftReadGeneration.current;
737
891
  const localAtStart = localEditRevision.current;
738
892
  const baseAtStart = draftRef.current;
739
- const extrasAtStart = resolveSendExtras(sendExtrasRef.current);
893
+ const policyAtStart = policyRef.current;
740
894
  const localSignatureAtStart = baseAtStart
741
- ? draftSignature(
742
- composerDraftPayload(
743
- baseAtStart,
744
- valueRef.current,
745
- restoredResourcesRef.current,
746
- annotationsRef.current,
747
- extrasAtStart,
748
- ),
749
- )
895
+ ? policyAtStart
896
+ ? draftSignature(
897
+ composerDraftPayload(
898
+ baseAtStart,
899
+ valueRef.current,
900
+ restoredResourcesRef.current,
901
+ annotationsRef.current,
902
+ policyAtStart,
903
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
904
+ ),
905
+ )
906
+ : null
750
907
  : null;
751
908
  const localWasDirtyAtStart =
752
909
  localSignatureAtStart === null
@@ -782,32 +939,35 @@ export function useComposer(
782
939
  valueRef.current = shadow.text;
783
940
  restoredResourcesRef.current = shadow.resources;
784
941
  annotationsRef.current = shadow.annotations;
942
+ if (shadow.policy) policyRef.current = shadow.policy;
785
943
  localEditRevision.current ||= 1;
786
944
  setValue(shadow.text);
787
945
  setRestoredResources(shadow.resources);
788
946
  setAnnotations(shadow.annotations);
947
+ if (shadow.policy) setPolicy(shadow.policy);
789
948
  } else if (
790
949
  replaceLocal ||
791
950
  (!localWasDirtyAtStart && localAtStart === localEditRevision.current)
792
951
  ) {
793
- // Model/effort/latency ride in sendExtras (outside
794
- // localEditRevision). If
795
- // the picker changed during this fetch, skip onDraftApplied so a
796
- // stale server policy cannot undo the operator's pick.
797
- const extrasNow = resolveSendExtras(sendExtrasRef.current);
798
- const pickerChangedDuringFetch =
799
- extrasNow.model !== extrasAtStart.model ||
800
- extrasNow.reasoningEffort !== extrasAtStart.reasoningEffort ||
801
- extrasNow.latencyMode !== extrasAtStart.latencyMode;
952
+ const fetchedPolicy = policyFromDraft(fetched);
802
953
  valueRef.current = fetched.text;
803
954
  restoredResourcesRef.current = fetched.resources;
804
955
  annotationsRef.current = fetched.annotations ?? [];
956
+ policyRef.current = fetchedPolicy;
805
957
  lastSavedSignature.current = draftSignature(draftPayload(fetched));
806
958
  setValue(fetched.text);
807
959
  setRestoredResources(fetched.resources);
808
960
  setAnnotations(fetched.annotations ?? []);
809
- if (replaceLocal || !pickerChangedDuringFetch) {
810
- onDraftAppliedRef.current?.(fetched);
961
+ setPolicy(fetchedPolicy);
962
+ } else {
963
+ // Keep the in-flight edit, but the fetched row is still the OCC
964
+ // base. First hydrate has no local policy until this read lands;
965
+ // without it autosave cannot persist the typed text.
966
+ lastSavedSignature.current = draftSignature(draftPayload(fetched));
967
+ if (!policyRef.current) {
968
+ const fetchedPolicy = policyFromDraft(fetched);
969
+ policyRef.current = fetchedPolicy;
970
+ setPolicy(fetchedPolicy);
811
971
  }
812
972
  }
813
973
  }
@@ -930,32 +1090,41 @@ export function useComposer(
930
1090
  }, [options.events, steering]);
931
1091
 
932
1092
  useEffect(() => {
933
- if (!controlStopping) return;
1093
+ if (!acceptedControl) return;
934
1094
  const effectiveControl = options.effectiveControl;
935
- if (!effectiveControl || effectiveControl.controlVersion < controlStopping.controlVersion) {
936
- return;
937
- }
938
- if (
939
- effectiveControl.controlVersion === controlStopping.controlVersion &&
940
- effectiveControl.controlEtag === controlStopping.controlEtag &&
941
- effectiveControl.settlement !== null
942
- ) {
1095
+ if (!effectiveControl || effectiveControl.controlVersion < acceptedControl.controlVersion) {
943
1096
  return;
944
1097
  }
945
- // A newer control revision, an impossible same-version/different-etag
946
- // projection, or quiescence at this exact revision supersedes the local
947
- // mutation receipt. SessionChrome then falls back to durable queue truth.
948
- setControlStopping((current) => (current === controlStopping ? null : current));
949
- }, [controlStopping, options.effectiveControl]);
1098
+ setAcceptedControl((current) => (current === acceptedControl ? null : current));
1099
+ }, [acceptedControl, options.effectiveControl]);
950
1100
 
951
1101
  const currentDraftPayload = useCallback((): SaveComposerDraftRequest | null => {
952
1102
  if (!durableDrafts || targetKeyRef.current !== targetKey) return null;
953
1103
  const base = draftRef.current;
954
- if (!base) return null;
955
- const extras = resolveSendExtras(sendExtrasRef.current);
956
- return composerDraftPayload(base, value, restoredResources, annotations, extras);
1104
+ const currentPolicy = policyRef.current;
1105
+ if (!base || !currentPolicy) return null;
1106
+ return composerDraftPayload(
1107
+ base,
1108
+ value,
1109
+ restoredResources,
1110
+ annotations,
1111
+ currentPolicy,
1112
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
1113
+ );
957
1114
  }, [annotations, durableDrafts, restoredResources, targetKey, value]);
958
1115
 
1116
+ const adoptDraftBase = useCallback(
1117
+ (next: ComposerDraft): void => {
1118
+ if (targetKeyRef.current !== targetKey) return;
1119
+ draftRef.current = next;
1120
+ setDraft(next);
1121
+ lastSavedSignature.current = draftSignature(draftPayload(next));
1122
+ setDraftConflict(null);
1123
+ setError(null);
1124
+ },
1125
+ [targetKey],
1126
+ );
1127
+
959
1128
  const persistPayload = useCallback(
960
1129
  async (payload: SaveComposerDraftRequest): Promise<boolean> => {
961
1130
  const ownedTargetKey = targetKey;
@@ -981,30 +1150,14 @@ export function useComposer(
981
1150
  }
982
1151
  setDraftSaving(true);
983
1152
  try {
984
- const saved = await saveComposerDraftWithStaleRetry({
985
- client,
986
- workspaceId,
987
- sessionId,
988
- request,
989
- onAdoptRemote: (remote) => {
990
- draftRef.current = remote;
991
- setDraft(remote);
992
- },
993
- });
1153
+ const saved = await client.saveComposerDraft(workspaceId, sessionId, request);
994
1154
  if (
995
1155
  targetKeyRef.current !== ownedTargetKey ||
996
1156
  targetGeneration.current !== ownedGeneration
997
1157
  ) {
998
1158
  return;
999
1159
  }
1000
- draftRef.current = saved;
1001
- setDraft(saved);
1002
- lastSavedSignature.current = draftSignature({
1003
- ...request,
1004
- expectedRevision: saved.revision,
1005
- });
1006
- setDraftConflict(null);
1007
- setError(null);
1160
+ adoptDraftBase(saved);
1008
1161
  success = true;
1009
1162
  } catch (cause) {
1010
1163
  if (
@@ -1028,7 +1181,7 @@ export function useComposer(
1028
1181
  await saveChain.current;
1029
1182
  return success;
1030
1183
  },
1031
- [client, durableDrafts, sessionId, targetKey, workspaceId],
1184
+ [adoptDraftBase, client, durableDrafts, sessionId, targetKey, workspaceId],
1032
1185
  );
1033
1186
 
1034
1187
  const replaceOptimisticSends = useCallback(
@@ -1058,17 +1211,46 @@ export function useComposer(
1058
1211
  );
1059
1212
 
1060
1213
  useEffect(() => {
1061
- const acceptedIds = new Set(
1062
- (options.events ?? [])
1063
- .filter((event) => event.type === "user.message" && event.clientEventId)
1064
- .map((event) => event.clientEventId as string),
1214
+ const events = options.events ?? [];
1215
+ if (events.length === 0 || optimisticSendsRef.current.length === 0) return;
1216
+ const reconciled = optimisticSendsRef.current.map((operation) =>
1217
+ reconcileOptimisticSendFromEvents(operation, events),
1065
1218
  );
1066
- if (acceptedIds.size === 0) return;
1067
- const accepted = optimisticSendsRef.current.filter((operation) =>
1068
- acceptedIds.has(operation.clientEventId),
1219
+ const settledIds = new Set(
1220
+ reconciled.filter((item) => item.settled).map((item) => item.operation.clientEventId),
1069
1221
  );
1070
- for (const operation of accepted) markOptimisticAccepted(operation);
1071
- }, [markOptimisticAccepted, options.events]);
1222
+ const changed = reconciled.some(
1223
+ (item, index) =>
1224
+ item.operation !== optimisticSendsRef.current[index] &&
1225
+ !settledIds.has(item.operation.clientEventId),
1226
+ );
1227
+ for (const item of reconciled) {
1228
+ if (item.admitted && !optimisticCallbackIdsRef.current.has(item.operation.clientEventId)) {
1229
+ optimisticCallbackIdsRef.current.add(item.operation.clientEventId);
1230
+ onSent?.(item.operation.input.text, item.operation.input);
1231
+ }
1232
+ if (item.settled) markOptimisticAccepted(item.operation);
1233
+ }
1234
+ if (changed) {
1235
+ replaceOptimisticSends((current) =>
1236
+ current
1237
+ .map(
1238
+ (operation) =>
1239
+ reconciled.find((item) => item.operation.clientEventId === operation.clientEventId)
1240
+ ?.operation ?? operation,
1241
+ )
1242
+ .filter((operation) => !settledIds.has(operation.clientEventId)),
1243
+ );
1244
+ }
1245
+ if (durableDrafts && reconciled.some((item) => item.admitted)) void loadDraft(false);
1246
+ }, [
1247
+ durableDrafts,
1248
+ loadDraft,
1249
+ markOptimisticAccepted,
1250
+ onSent,
1251
+ options.events,
1252
+ replaceOptimisticSends,
1253
+ ]);
1072
1254
 
1073
1255
  const processOptimisticSends = useCallback((): void => {
1074
1256
  if (!sessionId || optimisticProcessorBusyRef.current) return;
@@ -1082,20 +1264,69 @@ export function useComposer(
1082
1264
  const ownedGeneration = targetGeneration.current;
1083
1265
  optimisticProcessorBusyRef.current = true;
1084
1266
  void (async () => {
1267
+ let mutationFailureObserved = false;
1268
+ const markFailed = (problem: Error, outcomeUnknown: boolean): void => {
1269
+ if (
1270
+ targetKeyRef.current !== ownedTargetKey ||
1271
+ targetGeneration.current !== ownedGeneration
1272
+ ) {
1273
+ return;
1274
+ }
1275
+ replaceOptimisticSends((current) =>
1276
+ current.map((candidate) =>
1277
+ candidate.clientEventId === operation.clientEventId
1278
+ ? {
1279
+ ...candidate,
1280
+ state: "failed",
1281
+ error: problem.message,
1282
+ outcomeUnknown,
1283
+ }
1284
+ : candidate,
1285
+ ),
1286
+ );
1287
+ };
1085
1288
  try {
1086
1289
  if (operation.outcomeUnknown) {
1087
- const events = await client.listEvents(workspaceId, sessionId, {
1088
- includeTypes: ["user.message"],
1089
- limit: 100,
1090
- payloadMode: "none",
1091
- });
1092
- if (
1093
- events.some(
1094
- (event) =>
1095
- event.type === "user.message" && event.clientEventId === operation.clientEventId,
1096
- )
1097
- ) {
1098
- markOptimisticAccepted(operation);
1290
+ let events: SessionEvent[];
1291
+ try {
1292
+ events = await client.listEvents(workspaceId, sessionId, {
1293
+ includeTypes: [
1294
+ "user.message",
1295
+ "turn.queued",
1296
+ "turn.started",
1297
+ "turn.completed",
1298
+ "turn.failed",
1299
+ "turn.cancelled",
1300
+ "turn.superseded",
1301
+ ],
1302
+ limit: 250,
1303
+ payloadMode: "full",
1304
+ });
1305
+ } catch (cause) {
1306
+ // This read cannot settle the prior mutation in either direction.
1307
+ // Preserve its exact key and uncertain status until reconciliation
1308
+ // succeeds or that same mutation is actually replayed.
1309
+ markFailed(asError(cause), true);
1310
+ return;
1311
+ }
1312
+ const reconciled = reconcileOptimisticSendFromEvents(operation, events);
1313
+ if (reconciled.admitted) {
1314
+ if (durableDrafts) await loadDraft(false);
1315
+ if (reconciled.settled) {
1316
+ markOptimisticAccepted(reconciled.operation);
1317
+ } else {
1318
+ replaceOptimisticSends((current) =>
1319
+ current.map((candidate) =>
1320
+ candidate.clientEventId === operation.clientEventId
1321
+ ? reconciled.operation
1322
+ : candidate,
1323
+ ),
1324
+ );
1325
+ if (!optimisticCallbackIdsRef.current.has(operation.clientEventId)) {
1326
+ optimisticCallbackIdsRef.current.add(operation.clientEventId);
1327
+ onSent?.(reconciled.operation.input.text, reconciled.operation.input);
1328
+ }
1329
+ }
1099
1330
  return;
1100
1331
  }
1101
1332
  if (!operation.canRetry) {
@@ -1121,20 +1352,79 @@ export function useComposer(
1121
1352
  ...operation.input,
1122
1353
  ...(expectedDraftRevision !== undefined ? { expectedDraftRevision } : {}),
1123
1354
  };
1124
- await client.sendMessage(workspaceId, sessionId, wireInput);
1355
+ let acceptedResult: SubmitComposerDraftResponse | null = null;
1356
+ let acceptedEvent: SessionEvent | null = null;
1357
+ if (durableDrafts) {
1358
+ if (!operation.draftPayload || expectedDraftRevision === undefined) {
1359
+ throw new Error("The durable composer draft is not ready for delivery.");
1360
+ }
1361
+ const result = await client
1362
+ .submitComposerDraft(workspaceId, sessionId, {
1363
+ text: operation.draftPayload.text,
1364
+ annotations: operation.draftPayload.annotations,
1365
+ resources: operation.draftPayload.resources,
1366
+ model: operation.draftPayload.model,
1367
+ reasoningEffort: operation.draftPayload.reasoningEffort,
1368
+ latencyMode: operation.draftPayload.latencyMode,
1369
+ expectedDraftRevision,
1370
+ clientEventId: operation.clientEventId,
1371
+ delivery: "send",
1372
+ ...(wireInput.controlEtag ? { controlEtag: wireInput.controlEtag } : {}),
1373
+ ...(wireInput.modelContext ? { modelContext: wireInput.modelContext } : {}),
1374
+ ...(wireInput.mcpCredentialUpdates
1375
+ ? { mcpCredentialUpdates: wireInput.mcpCredentialUpdates }
1376
+ : {}),
1377
+ ...(wireInput.connectionAuthorities
1378
+ ? { connectionAuthorities: wireInput.connectionAuthorities }
1379
+ : {}),
1380
+ ...(wireInput.personalResourceAttachment
1381
+ ? { personalResourceAttachment: wireInput.personalResourceAttachment }
1382
+ : {}),
1383
+ })
1384
+ .catch((cause: unknown) => {
1385
+ mutationFailureObserved = true;
1386
+ throw cause;
1387
+ });
1388
+ acceptedResult = result;
1389
+ adoptDraftBase(result.draft);
1390
+ } else {
1391
+ acceptedEvent = await client
1392
+ .sendMessage(workspaceId, sessionId, wireInput)
1393
+ .catch((cause: unknown) => {
1394
+ mutationFailureObserved = true;
1395
+ throw cause;
1396
+ });
1397
+ }
1125
1398
  if (
1126
1399
  targetKeyRef.current !== ownedTargetKey ||
1127
1400
  targetGeneration.current !== ownedGeneration
1128
1401
  ) {
1129
1402
  return;
1130
1403
  }
1131
- if (options.events === undefined) {
1404
+ const acceptedDestination =
1405
+ acceptedResult?.routing === "queued_for_execution"
1406
+ ? "queue"
1407
+ : acceptedResult?.routing === "accepted_for_execution"
1408
+ ? "chat"
1409
+ : operation.destination;
1410
+ if (options.events === undefined && acceptedDestination === "chat") {
1132
1411
  markOptimisticAccepted({ ...operation, input: wireInput });
1133
1412
  } else {
1134
1413
  replaceOptimisticSends((current) =>
1135
1414
  current.map((candidate) =>
1136
1415
  candidate.clientEventId === operation.clientEventId
1137
- ? { ...candidate, input: wireInput, state: "queued", error: undefined }
1416
+ ? {
1417
+ ...candidate,
1418
+ input: wireInput,
1419
+ destination: acceptedDestination,
1420
+ turnId: acceptedResult?.turn.id ?? candidate.turnId,
1421
+ triggerEventId:
1422
+ acceptedResult?.accepted.id ?? acceptedEvent?.id ?? candidate.triggerEventId,
1423
+ appliedQueueVersion:
1424
+ acceptedResult?.receipt?.appliedQueueVersion ?? candidate.appliedQueueVersion,
1425
+ state: "queued",
1426
+ error: undefined,
1427
+ }
1138
1428
  : candidate,
1139
1429
  ),
1140
1430
  );
@@ -1143,41 +1433,24 @@ export function useComposer(
1143
1433
  optimisticCallbackIdsRef.current.add(operation.clientEventId);
1144
1434
  onSent?.(wireInput.text, wireInput);
1145
1435
  }
1146
- if (durableDrafts && draftRef.current) {
1147
- const cleared = {
1148
- ...draftRef.current,
1149
- revision: 0,
1150
- text: "",
1151
- resources: [],
1152
- annotations: [],
1153
- sourceTurnId: null,
1154
- sourceTurnVersion: null,
1155
- updatedAt: null,
1156
- };
1157
- draftRef.current = cleared;
1158
- setDraft(cleared);
1159
- lastSavedSignature.current = draftSignature(draftPayload(cleared));
1160
- }
1161
1436
  } catch (cause) {
1162
- const problem = asError(cause);
1163
- const outcomeUnknown = isOutcomeUnknownError(cause) || operation.outcomeUnknown === true;
1164
- if (
1165
- targetKeyRef.current === ownedTargetKey &&
1166
- targetGeneration.current === ownedGeneration
1167
- ) {
1168
- replaceOptimisticSends((current) =>
1169
- current.map((candidate) =>
1170
- candidate.clientEventId === operation.clientEventId
1171
- ? {
1172
- ...candidate,
1173
- state: "failed",
1174
- error: problem.message,
1175
- outcomeUnknown,
1176
- }
1177
- : candidate,
1178
- ),
1179
- );
1437
+ const current = optimisticSendsRef.current.find(
1438
+ (candidate) => candidate.clientEventId === operation.clientEventId,
1439
+ );
1440
+ // A live durable event can prove admission before the original HTTP
1441
+ // response times out. Never regress that confirmed state to failure.
1442
+ if (!current || (current.state === "queued" && current.outcomeUnknown === false)) {
1443
+ return;
1180
1444
  }
1445
+ const problem = asError(cause);
1446
+ onDeliveryErrorRef.current?.(problem, operation.input, "send");
1447
+ // Only an actual mutation replay can replace prior uncertainty with a
1448
+ // definitive response. Local preparation failures remain retry-only.
1449
+ const outcomeUnknown =
1450
+ operation.outcomeUnknown && !mutationFailureObserved
1451
+ ? true
1452
+ : isOutcomeUnknownError(cause);
1453
+ markFailed(problem, outcomeUnknown);
1181
1454
  } finally {
1182
1455
  optimisticProcessorBusyRef.current = false;
1183
1456
  if (targetKeyRef.current === ownedTargetKey) {
@@ -1186,9 +1459,11 @@ export function useComposer(
1186
1459
  }
1187
1460
  })();
1188
1461
  }, [
1462
+ adoptDraftBase,
1189
1463
  client,
1190
1464
  durableDrafts,
1191
1465
  markOptimisticAccepted,
1466
+ loadDraft,
1192
1467
  onSent,
1193
1468
  options.events,
1194
1469
  persistPayload,
@@ -1214,11 +1489,13 @@ export function useComposer(
1214
1489
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
1215
1490
  ),
1216
1491
  annotations: annotationsRef.current,
1492
+ ...(policyRef.current ? { policy: policyRef.current } : {}),
1217
1493
  };
1218
1494
  if (
1219
1495
  pending.newerShadow.text !== shadow.text ||
1220
1496
  JSON.stringify(pending.newerShadow.resources) !== JSON.stringify(shadow.resources) ||
1221
- JSON.stringify(pending.newerShadow.annotations) !== JSON.stringify(shadow.annotations)
1497
+ JSON.stringify(pending.newerShadow.annotations) !== JSON.stringify(shadow.annotations) ||
1498
+ JSON.stringify(pending.newerShadow.policy) !== JSON.stringify(shadow.policy)
1222
1499
  ) {
1223
1500
  pendingOperationRef.current = updatePendingComposerShadow(
1224
1501
  pendingOperationKey,
@@ -1237,11 +1514,13 @@ export function useComposer(
1237
1514
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
1238
1515
  ),
1239
1516
  annotations: annotationsRef.current,
1517
+ ...(policyRef.current ? { policy: policyRef.current } : {}),
1240
1518
  };
1241
1519
  if (
1242
1520
  optimistic.newerShadow.text !== shadow.text ||
1243
1521
  JSON.stringify(optimistic.newerShadow.resources) !== JSON.stringify(shadow.resources) ||
1244
- JSON.stringify(optimistic.newerShadow.annotations) !== JSON.stringify(shadow.annotations)
1522
+ JSON.stringify(optimistic.newerShadow.annotations) !== JSON.stringify(shadow.annotations) ||
1523
+ JSON.stringify(optimistic.newerShadow.policy) !== JSON.stringify(shadow.policy)
1245
1524
  ) {
1246
1525
  setOptimisticDraftShadow(shadow);
1247
1526
  }
@@ -1290,6 +1569,7 @@ export function useComposer(
1290
1569
  const rawText = explicit ?? draftAtSend;
1291
1570
  const hasText = rawText.trim().length > 0;
1292
1571
  const hasAnnotations = annotationsAtSend.length > 0;
1572
+ const currentPolicy = policyRef.current;
1293
1573
  const annotationsComplete = annotationsAtSend.every(
1294
1574
  (annotation) => annotation.note.trim().length > 0,
1295
1575
  );
@@ -1300,6 +1580,7 @@ export function useComposer(
1300
1580
  if (
1301
1581
  (!pending && (!annotationsComplete || (!hasText && !hasResources && !hasAnnotations))) ||
1302
1582
  !sessionId ||
1583
+ !currentPolicy ||
1303
1584
  sending ||
1304
1585
  sendBlockedRef.current?.() === true ||
1305
1586
  targetKeyRef.current !== ownedTargetKey
@@ -1323,22 +1604,6 @@ export function useComposer(
1323
1604
  JSON.stringify(operation.resourcesAtSend);
1324
1605
  const annotationsWereUnchanged =
1325
1606
  JSON.stringify(annotationsRef.current) === JSON.stringify(operation.annotationsAtSend);
1326
- const previousDraft = draftRef.current;
1327
- if (previousDraft) {
1328
- const cleared = {
1329
- ...previousDraft,
1330
- revision: 0,
1331
- text: "",
1332
- resources: [],
1333
- annotations: [],
1334
- sourceTurnId: null,
1335
- sourceTurnVersion: null,
1336
- updatedAt: null,
1337
- };
1338
- draftRef.current = cleared;
1339
- setDraft(cleared);
1340
- lastSavedSignature.current = draftSignature(draftPayload(cleared));
1341
- }
1342
1607
  if (resourcesWereUnchanged) {
1343
1608
  restoredResourcesRef.current = [];
1344
1609
  setRestoredResources([]);
@@ -1356,6 +1621,42 @@ export function useComposer(
1356
1621
  };
1357
1622
 
1358
1623
  const deliver = async (operation: PendingComposerOperation) => {
1624
+ if (durableDrafts) {
1625
+ const input = operation.input;
1626
+ if (
1627
+ input.expectedDraftRevision === undefined ||
1628
+ input.clientEventId === undefined ||
1629
+ input.model === undefined ||
1630
+ input.reasoningEffort === undefined ||
1631
+ input.latencyMode === undefined
1632
+ ) {
1633
+ throw new Error("The durable composer draft is not ready for delivery.");
1634
+ }
1635
+ const result = await client.submitComposerDraft(workspaceId, sessionId, {
1636
+ text: input.text,
1637
+ annotations: input.annotations ?? [],
1638
+ resources: input.resources ?? [],
1639
+ model: input.model,
1640
+ reasoningEffort: input.reasoningEffort,
1641
+ latencyMode: input.latencyMode,
1642
+ expectedDraftRevision: input.expectedDraftRevision,
1643
+ clientEventId: input.clientEventId,
1644
+ delivery: operation.delivery,
1645
+ ...(input.controlEtag ? { controlEtag: input.controlEtag } : {}),
1646
+ ...(input.modelContext ? { modelContext: input.modelContext } : {}),
1647
+ ...(input.mcpCredentialUpdates
1648
+ ? { mcpCredentialUpdates: input.mcpCredentialUpdates }
1649
+ : {}),
1650
+ ...(input.connectionAuthorities
1651
+ ? { connectionAuthorities: input.connectionAuthorities }
1652
+ : {}),
1653
+ ...(input.personalResourceAttachment
1654
+ ? { personalResourceAttachment: input.personalResourceAttachment }
1655
+ : {}),
1656
+ });
1657
+ adoptDraftBase(result.draft);
1658
+ return result;
1659
+ }
1359
1660
  if (operation.delivery === "steer") {
1360
1661
  return await client.steerMessage(workspaceId, sessionId, operation.input);
1361
1662
  }
@@ -1364,6 +1665,9 @@ export function useComposer(
1364
1665
  };
1365
1666
 
1366
1667
  if (delivery === "steer") {
1668
+ if (steeringRef.current?.clientEventId !== pending?.input.clientEventId) {
1669
+ steeringOccurredAtRef.current = new Date().toISOString();
1670
+ }
1367
1671
  setSteering({
1368
1672
  phase: "submitting",
1369
1673
  text: rawText,
@@ -1394,7 +1698,20 @@ export function useComposer(
1394
1698
  targetKeyRef.current === ownedTargetKey &&
1395
1699
  targetGeneration.current === ownedGeneration
1396
1700
  ) {
1397
- setError(asError(cause));
1701
+ const problem = asError(cause);
1702
+ setError(problem);
1703
+ if (pending.delivery === "steer") {
1704
+ keepSteering = true;
1705
+ setSteering({
1706
+ phase: "failed",
1707
+ text: pending.input.text,
1708
+ clientEventId: pending.input.clientEventId ?? null,
1709
+ triggerEventId: null,
1710
+ turnId: null,
1711
+ error: "Not confirmed. Retry.",
1712
+ outcomeUnknown: true,
1713
+ });
1714
+ }
1398
1715
  }
1399
1716
  return false;
1400
1717
  }
@@ -1405,6 +1722,7 @@ export function useComposer(
1405
1722
  return false;
1406
1723
  }
1407
1724
  if (acceptedEvent) {
1725
+ if (durableDrafts) await loadDraft(false);
1408
1726
  if (pending.delivery === "steer") {
1409
1727
  keepSteering = true;
1410
1728
  setSteering({
@@ -1441,12 +1759,29 @@ export function useComposer(
1441
1759
  });
1442
1760
  }
1443
1761
  } catch (cause) {
1444
- if (pending.delivery === "steer") keepSteering = true;
1762
+ const problem = asError(cause);
1763
+ const outcomeUnknown = isOutcomeUnknownError(cause);
1764
+ onDeliveryErrorRef.current?.(problem, pending.input, pending.delivery);
1765
+ if (!outcomeUnknown) {
1766
+ clearPending();
1767
+ keepSteering = false;
1768
+ } else if (pending.delivery === "steer") {
1769
+ keepSteering = true;
1770
+ setSteering({
1771
+ phase: "failed",
1772
+ text: pending.input.text,
1773
+ clientEventId: pending.input.clientEventId ?? null,
1774
+ triggerEventId: null,
1775
+ turnId: null,
1776
+ error: "Not confirmed. Retry.",
1777
+ outcomeUnknown: true,
1778
+ });
1779
+ }
1445
1780
  if (
1446
1781
  targetKeyRef.current === ownedTargetKey &&
1447
1782
  targetGeneration.current === ownedGeneration
1448
1783
  ) {
1449
- setError(asError(cause));
1784
+ setError(problem);
1450
1785
  }
1451
1786
  return false;
1452
1787
  }
@@ -1478,10 +1813,10 @@ export function useComposer(
1478
1813
  // worker rejects whitespace-only text; a file-only message therefore
1479
1814
  // carries a minimal default so the attachments still get delivered.
1480
1815
  pendingClientEventId.current ??= generateClientEventId();
1816
+ const effectiveControl = effectiveControlRef.current;
1481
1817
  const input = composeSendInput(sendText, pendingClientEventId.current, extras, {
1482
- ...(options.effectiveControl?.controlEtag
1483
- ? { controlEtag: options.effectiveControl.controlEtag }
1484
- : {}),
1818
+ ...currentPolicy,
1819
+ ...(effectiveControl?.controlEtag ? { controlEtag: effectiveControl.controlEtag } : {}),
1485
1820
  ...(durableDrafts && draftRef.current
1486
1821
  ? { expectedDraftRevision: draftRef.current.revision }
1487
1822
  : {}),
@@ -1498,6 +1833,7 @@ export function useComposer(
1498
1833
  text: draftAtSend,
1499
1834
  resources: mergeResources(restoredResources, extras.resources ?? []),
1500
1835
  annotations: cloneAnnotations(annotationsAtSend),
1836
+ policy: currentPolicy,
1501
1837
  },
1502
1838
  clearDraftOnAccept: explicit === undefined,
1503
1839
  canRetry: true,
@@ -1528,10 +1864,20 @@ export function useComposer(
1528
1864
  });
1529
1865
  }
1530
1866
  } catch (cause) {
1867
+ onDeliveryErrorRef.current?.(asError(cause), operation.input, operation.delivery);
1531
1868
  if (!isOutcomeUnknownError(cause)) {
1532
1869
  clearPending();
1533
1870
  } else if (delivery === "steer") {
1534
1871
  keepSteering = true;
1872
+ setSteering({
1873
+ phase: "failed",
1874
+ text: operation.input.text,
1875
+ clientEventId: operation.input.clientEventId ?? null,
1876
+ triggerEventId: null,
1877
+ turnId: null,
1878
+ error: "Not confirmed. Retry.",
1879
+ outcomeUnknown: true,
1880
+ });
1535
1881
  }
1536
1882
  if (
1537
1883
  targetKeyRef.current === ownedTargetKey &&
@@ -1564,15 +1910,16 @@ export function useComposer(
1564
1910
  currentDraftPayload,
1565
1911
  durableDrafts,
1566
1912
  onSent,
1567
- options.effectiveControl?.controlEtag,
1568
1913
  persistPayload,
1569
1914
  restoredResources,
1570
1915
  annotations,
1916
+ adoptDraftBase,
1571
1917
  sending,
1572
1918
  sessionId,
1573
1919
  targetKey,
1574
1920
  value,
1575
1921
  workspaceId,
1922
+ loadDraft,
1576
1923
  ],
1577
1924
  );
1578
1925
 
@@ -1583,6 +1930,7 @@ export function useComposer(
1583
1930
  }
1584
1931
  const rawText = explicit ?? valueRef.current;
1585
1932
  const annotationsAtSend = cloneAnnotations(annotationsRef.current);
1933
+ const currentPolicy = policyRef.current;
1586
1934
  const hasText = rawText.trim().length > 0;
1587
1935
  const hasAnnotations = annotationsAtSend.length > 0;
1588
1936
  const annotationsComplete = annotationsAtSend.every(
@@ -1592,6 +1940,7 @@ export function useComposer(
1592
1940
  const resources = mergeResources(restoredResourcesRef.current, extras.resources ?? []);
1593
1941
  if (
1594
1942
  !sessionId ||
1943
+ !currentPolicy ||
1595
1944
  sending ||
1596
1945
  !annotationsComplete ||
1597
1946
  (!hasText && !hasAnnotations && resources.length === 0) ||
@@ -1602,16 +1951,24 @@ export function useComposer(
1602
1951
  }
1603
1952
  const sendText = hasText ? rawText : hasAnnotations ? "" : FILE_ONLY_MESSAGE_TEXT;
1604
1953
  const clientEventId = generateClientEventId();
1954
+ const effectiveControl = effectiveControlRef.current;
1605
1955
  const input = composeSendInput(sendText, clientEventId, extras, {
1606
- ...(options.effectiveControl?.controlEtag
1607
- ? { controlEtag: options.effectiveControl.controlEtag }
1608
- : {}),
1956
+ ...currentPolicy,
1957
+ ...(effectiveControl?.controlEtag ? { controlEtag: effectiveControl.controlEtag } : {}),
1609
1958
  resources,
1610
1959
  annotations: annotationsAtSend,
1611
1960
  });
1612
1961
  const currentPayload = currentDraftPayload();
1962
+ const hasEarlierUnsettledSend = optimisticSendsRef.current.some(
1963
+ (candidate) => candidate.state !== "failed" || candidate.outcomeUnknown === true,
1964
+ );
1613
1965
  const operation: OptimisticSendOperation = {
1614
1966
  clientEventId,
1967
+ delivery: "send",
1968
+ destination:
1969
+ effectiveControl?.state === "paused" || hasEarlierUnsettledSend
1970
+ ? "queue"
1971
+ : (sendDestinationRef.current?.() ?? "chat"),
1615
1972
  text: sendText,
1616
1973
  annotations: annotationsAtSend,
1617
1974
  resources,
@@ -1623,6 +1980,7 @@ export function useComposer(
1623
1980
  text: explicit === undefined ? "" : valueRef.current,
1624
1981
  resources: explicit === undefined ? [] : [...restoredResourcesRef.current],
1625
1982
  annotations: explicit === undefined ? [] : cloneAnnotations(annotationsRef.current),
1983
+ policy: currentPolicy,
1626
1984
  },
1627
1985
  canRetry: true,
1628
1986
  };
@@ -1646,7 +2004,6 @@ export function useComposer(
1646
2004
  currentDraftPayload,
1647
2005
  dispatch,
1648
2006
  onSubmitted,
1649
- options.effectiveControl?.controlEtag,
1650
2007
  processOptimisticSends,
1651
2008
  replaceOptimisticSends,
1652
2009
  sending,
@@ -1658,6 +2015,12 @@ export function useComposer(
1658
2015
 
1659
2016
  const retryOptimisticMessage = useCallback(
1660
2017
  (clientEventId: string): void => {
2018
+ if (sendBlockedRef.current?.() === true) return;
2019
+ const currentSteering = steeringRef.current;
2020
+ if (currentSteering?.phase === "failed" && currentSteering.clientEventId === clientEventId) {
2021
+ void dispatch("steer");
2022
+ return;
2023
+ }
1661
2024
  replaceOptimisticSends((current) =>
1662
2025
  current.map((operation) => {
1663
2026
  if (operation.clientEventId !== clientEventId || operation.state !== "failed") {
@@ -1667,27 +2030,31 @@ export function useComposer(
1667
2030
  return { ...operation, state: "sending", error: undefined };
1668
2031
  }
1669
2032
  const nextClientEventId = generateClientEventId();
1670
- const retryExtras = resolveSendExtras(sendExtrasRef.current);
1671
- const retryInput = composeSendInput(operation.text, nextClientEventId, retryExtras, {
1672
- ...(options.effectiveControl?.controlEtag
1673
- ? { controlEtag: options.effectiveControl.controlEtag }
1674
- : {}),
1675
- resources: operation.resources,
1676
- annotations: operation.annotations,
1677
- });
2033
+ const currentPersonalResourceAttachment = resolveSendExtras(
2034
+ sendExtrasRef.current,
2035
+ ).personalResourceAttachment;
2036
+ const retryInput = composeSendInput(
2037
+ operation.text,
2038
+ nextClientEventId,
2039
+ {
2040
+ ...operation.input,
2041
+ ...(currentPersonalResourceAttachment
2042
+ ? { personalResourceAttachment: currentPersonalResourceAttachment }
2043
+ : {}),
2044
+ },
2045
+ {
2046
+ ...(effectiveControlRef.current?.controlEtag
2047
+ ? { controlEtag: effectiveControlRef.current.controlEtag }
2048
+ : {}),
2049
+ resources: operation.resources,
2050
+ annotations: operation.annotations,
2051
+ },
2052
+ );
1678
2053
  return {
1679
2054
  ...operation,
1680
2055
  clientEventId: nextClientEventId,
1681
2056
  input: retryInput,
1682
- draftPayload: operation.draftPayload
1683
- ? {
1684
- ...operation.draftPayload,
1685
- model: retryExtras.model ?? operation.draftPayload.model,
1686
- reasoningEffort:
1687
- retryExtras.reasoningEffort ?? operation.draftPayload.reasoningEffort,
1688
- latencyMode: retryExtras.latencyMode ?? operation.draftPayload.latencyMode,
1689
- }
1690
- : null,
2057
+ draftPayload: operation.draftPayload,
1691
2058
  occurredAt: new Date().toISOString(),
1692
2059
  state: "sending",
1693
2060
  error: undefined,
@@ -1698,16 +2065,24 @@ export function useComposer(
1698
2065
  );
1699
2066
  queueMicrotask(processOptimisticSends);
1700
2067
  },
1701
- [options.effectiveControl?.controlEtag, processOptimisticSends, replaceOptimisticSends],
2068
+ [dispatch, processOptimisticSends, replaceOptimisticSends],
1702
2069
  );
1703
2070
 
1704
2071
  const removeOptimisticMessage = useCallback(
1705
2072
  (clientEventId: string): void => {
2073
+ if (steeringRef.current?.clientEventId === clientEventId) {
2074
+ pendingOperationRef.current = null;
2075
+ pendingClientEventId.current = null;
2076
+ forgetPendingComposerOperation(pendingComposerOperationKey(workspaceId, sessionId));
2077
+ setSteering(null);
2078
+ setError(null);
2079
+ return;
2080
+ }
1706
2081
  replaceOptimisticSends((current) =>
1707
2082
  current.filter((operation) => operation.clientEventId !== clientEventId),
1708
2083
  );
1709
2084
  },
1710
- [replaceOptimisticSends],
2085
+ [replaceOptimisticSends, sessionId, workspaceId],
1711
2086
  );
1712
2087
 
1713
2088
  // A send is possible with non-empty text OR with ≥1 attached resource (a
@@ -1731,26 +2106,22 @@ export function useComposer(
1731
2106
  setPausing(true);
1732
2107
  setError(null);
1733
2108
  try {
1734
- const result = await client.pauseSession(workspaceId, sessionId, {
1735
- ...(reason !== undefined ? { reason } : {}),
1736
- ...(options.effectiveControl?.controlEtag
1737
- ? { expectedControlEtag: options.effectiveControl.controlEtag }
1738
- : {}),
1739
- });
2109
+ const clientEventId = generateClientEventId();
2110
+ const effectiveControl = effectiveControlRef.current;
2111
+ const result = await replayOutcomeUnknown(() =>
2112
+ client.pauseSession(workspaceId, sessionId, {
2113
+ clientEventId,
2114
+ ...(reason !== undefined ? { reason } : {}),
2115
+ ...(effectiveControl?.controlEtag
2116
+ ? { expectedControlEtag: effectiveControl.controlEtag }
2117
+ : {}),
2118
+ }),
2119
+ );
1740
2120
  if (
1741
2121
  targetKeyRef.current === ownedTargetKey &&
1742
2122
  targetGeneration.current === ownedGeneration
1743
2123
  ) {
1744
- setControlStopping(
1745
- result.interruptionCount > 0 &&
1746
- result.effectiveControl.state === "paused" &&
1747
- result.effectiveControl.settlement !== null
1748
- ? {
1749
- controlVersion: result.effectiveControl.controlVersion,
1750
- controlEtag: result.effectiveControl.controlEtag,
1751
- }
1752
- : null,
1753
- );
2124
+ setAcceptedControl(result.effectiveControl);
1754
2125
  }
1755
2126
  } catch (cause) {
1756
2127
  if (
@@ -1768,7 +2139,7 @@ export function useComposer(
1768
2139
  }
1769
2140
  }
1770
2141
  },
1771
- [client, workspaceId, sessionId, pausing, options.effectiveControl?.controlEtag, targetKey],
2142
+ [client, workspaceId, sessionId, pausing, targetKey],
1772
2143
  );
1773
2144
 
1774
2145
  const resume = useCallback(
@@ -1779,12 +2150,23 @@ export function useComposer(
1779
2150
  setResuming(true);
1780
2151
  setError(null);
1781
2152
  try {
1782
- await client.resumeSession(workspaceId, sessionId, {
1783
- ...(reason !== undefined ? { reason } : {}),
1784
- ...(options.effectiveControl?.controlEtag
1785
- ? { expectedControlEtag: options.effectiveControl.controlEtag }
1786
- : {}),
1787
- });
2153
+ const clientEventId = generateClientEventId();
2154
+ const effectiveControl = effectiveControlRef.current;
2155
+ const result = await replayOutcomeUnknown(() =>
2156
+ client.resumeSession(workspaceId, sessionId, {
2157
+ clientEventId,
2158
+ ...(reason !== undefined ? { reason } : {}),
2159
+ ...(effectiveControl?.controlEtag
2160
+ ? { expectedControlEtag: effectiveControl.controlEtag }
2161
+ : {}),
2162
+ }),
2163
+ );
2164
+ if (
2165
+ targetKeyRef.current === ownedTargetKey &&
2166
+ targetGeneration.current === ownedGeneration
2167
+ ) {
2168
+ setAcceptedControl(result.effectiveControl);
2169
+ }
1788
2170
  } catch (cause) {
1789
2171
  if (
1790
2172
  targetKeyRef.current === ownedTargetKey &&
@@ -1801,7 +2183,7 @@ export function useComposer(
1801
2183
  }
1802
2184
  }
1803
2185
  },
1804
- [client, workspaceId, sessionId, resuming, options.effectiveControl?.controlEtag, targetKey],
2186
+ [client, workspaceId, sessionId, resuming, targetKey],
1805
2187
  );
1806
2188
 
1807
2189
  const resumeScope = useCallback(
@@ -1813,7 +2195,7 @@ export function useComposer(
1813
2195
  setError(null);
1814
2196
  try {
1815
2197
  if (option.scope === "workspace") {
1816
- const workspaceBlocker = options.effectiveControl?.blockers.find(
2198
+ const workspaceBlocker = effectiveControlRef.current?.blockers.find(
1817
2199
  (blocker) => blocker.kind === "workspace",
1818
2200
  );
1819
2201
  if (!client.setWorkspaceInferenceState) {
@@ -1821,11 +2203,14 @@ export function useComposer(
1821
2203
  "@opengeni/react: workspace-scoped resume requires setWorkspaceInferenceState.",
1822
2204
  );
1823
2205
  }
1824
- await client.setWorkspaceInferenceState(workspaceId, {
1825
- action: "resume",
1826
- clientEventId: generateClientEventId(),
1827
- ...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}),
1828
- });
2206
+ const clientEventId = generateClientEventId();
2207
+ await replayOutcomeUnknown(() =>
2208
+ client.setWorkspaceInferenceState!(workspaceId, {
2209
+ action: "resume",
2210
+ clientEventId,
2211
+ ...(workspaceBlocker ? { expectedRevision: workspaceBlocker.revision } : {}),
2212
+ }),
2213
+ );
1829
2214
  } else if (option.scope === "session" && option.targetId) {
1830
2215
  const target = await client.getQueue(workspaceId, option.targetId);
1831
2216
  if (
@@ -1834,15 +2219,30 @@ export function useComposer(
1834
2219
  ) {
1835
2220
  return;
1836
2221
  }
1837
- await client.resumeSession(workspaceId, option.targetId, {
1838
- expectedControlEtag: target.effectiveControl.controlEtag,
1839
- });
2222
+ const clientEventId = generateClientEventId();
2223
+ await replayOutcomeUnknown(() =>
2224
+ client.resumeSession(workspaceId, option.targetId!, {
2225
+ clientEventId,
2226
+ expectedControlEtag: target.effectiveControl.controlEtag,
2227
+ }),
2228
+ );
1840
2229
  } else {
1841
- await client.resumeSession(workspaceId, sessionId, {
1842
- ...(options.effectiveControl?.controlEtag
1843
- ? { expectedControlEtag: options.effectiveControl.controlEtag }
1844
- : {}),
1845
- });
2230
+ const clientEventId = generateClientEventId();
2231
+ const effectiveControl = effectiveControlRef.current;
2232
+ const result = await replayOutcomeUnknown(() =>
2233
+ client.resumeSession(workspaceId, sessionId, {
2234
+ clientEventId,
2235
+ ...(effectiveControl?.controlEtag
2236
+ ? { expectedControlEtag: effectiveControl.controlEtag }
2237
+ : {}),
2238
+ }),
2239
+ );
2240
+ if (
2241
+ targetKeyRef.current === ownedTargetKey &&
2242
+ targetGeneration.current === ownedGeneration
2243
+ ) {
2244
+ setAcceptedControl(result.effectiveControl);
2245
+ }
1846
2246
  }
1847
2247
  } catch (cause) {
1848
2248
  if (
@@ -1860,7 +2260,7 @@ export function useComposer(
1860
2260
  }
1861
2261
  }
1862
2262
  },
1863
- [client, options.effectiveControl, resuming, sessionId, targetKey, workspaceId],
2263
+ [client, resuming, sessionId, targetKey, workspaceId],
1864
2264
  );
1865
2265
 
1866
2266
  const updateValue = useCallback(
@@ -1878,6 +2278,7 @@ export function useComposer(
1878
2278
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
1879
2279
  ),
1880
2280
  annotations: annotationsRef.current,
2281
+ policy: policyRef.current ?? undefined,
1881
2282
  },
1882
2283
  );
1883
2284
  setOptimisticDraftShadow({
@@ -1887,6 +2288,7 @@ export function useComposer(
1887
2288
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
1888
2289
  ),
1889
2290
  annotations: annotationsRef.current,
2291
+ policy: policyRef.current ?? undefined,
1890
2292
  });
1891
2293
  setValue(next);
1892
2294
  },
@@ -1908,6 +2310,7 @@ export function useComposer(
1908
2310
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
1909
2311
  ),
1910
2312
  annotations: next,
2313
+ policy: policyRef.current ?? undefined,
1911
2314
  },
1912
2315
  );
1913
2316
  setOptimisticDraftShadow({
@@ -1917,6 +2320,7 @@ export function useComposer(
1917
2320
  resolveSendExtras(sendExtrasRef.current).resources ?? [],
1918
2321
  ),
1919
2322
  annotations: next,
2323
+ policy: policyRef.current ?? undefined,
1920
2324
  });
1921
2325
  setAnnotations(next);
1922
2326
  setAnnotationReviewTargetId(reviewTargetId);
@@ -1958,6 +2362,55 @@ export function useComposer(
1958
2362
  setAnnotationReviewTargetId(null);
1959
2363
  }, []);
1960
2364
 
2365
+ const updatePolicy = useCallback(
2366
+ (next: ComposerPolicy): void => {
2367
+ if (targetKeyRef.current !== targetKey) return;
2368
+ localEditRevision.current += 1;
2369
+ policyRef.current = next;
2370
+ const shadow = {
2371
+ text: valueRef.current,
2372
+ resources: mergeResources(
2373
+ restoredResourcesRef.current,
2374
+ resolveSendExtras(sendExtrasRef.current).resources ?? [],
2375
+ ),
2376
+ annotations: annotationsRef.current,
2377
+ policy: next,
2378
+ };
2379
+ pendingOperationRef.current = updatePendingComposerShadow(
2380
+ pendingOperationKey,
2381
+ pendingOperationRef.current,
2382
+ shadow,
2383
+ );
2384
+ setOptimisticDraftShadow(shadow);
2385
+ setPolicy(next);
2386
+ },
2387
+ [pendingOperationKey, setOptimisticDraftShadow, targetKey],
2388
+ );
2389
+
2390
+ const updateModel = useCallback(
2391
+ (model: string): void => {
2392
+ const current = policyRef.current;
2393
+ if (current) updatePolicy({ ...current, model });
2394
+ },
2395
+ [updatePolicy],
2396
+ );
2397
+
2398
+ const updateReasoningEffort = useCallback(
2399
+ (reasoningEffort: ReasoningEffort): void => {
2400
+ const current = policyRef.current;
2401
+ if (current) updatePolicy({ ...current, reasoningEffort });
2402
+ },
2403
+ [updatePolicy],
2404
+ );
2405
+
2406
+ const updateLatencyMode = useCallback(
2407
+ (latencyMode: LatencyMode): void => {
2408
+ const current = policyRef.current;
2409
+ if (current) updatePolicy({ ...current, latencyMode });
2410
+ },
2411
+ [updatePolicy],
2412
+ );
2413
+
1961
2414
  const removeRestoredResource = useCallback(
1962
2415
  (index: number) => {
1963
2416
  if (targetKeyRef.current !== targetKey) return;
@@ -1971,12 +2424,14 @@ export function useComposer(
1971
2424
  text: valueRef.current,
1972
2425
  resources: mergeResources(next, resolveSendExtras(sendExtrasRef.current).resources ?? []),
1973
2426
  annotations: annotationsRef.current,
2427
+ policy: policyRef.current ?? undefined,
1974
2428
  },
1975
2429
  );
1976
2430
  setOptimisticDraftShadow({
1977
2431
  text: valueRef.current,
1978
2432
  resources: mergeResources(next, resolveSendExtras(sendExtrasRef.current).resources ?? []),
1979
2433
  annotations: annotationsRef.current,
2434
+ policy: policyRef.current ?? undefined,
1980
2435
  });
1981
2436
  setRestoredResources(next);
1982
2437
  },
@@ -2030,6 +2485,44 @@ export function useComposer(
2030
2485
 
2031
2486
  const identityMatches = stateTargetKey === targetKey;
2032
2487
  const visibleSteering = identityMatches ? steering : null;
2488
+ const visibleOptimisticMessages: ComposerOptimisticMessage[] = identityMatches
2489
+ ? [
2490
+ ...optimisticSends.map(
2491
+ ({ input: _input, draftPayload: _payload, canRetry: _retry, ...item }) => item,
2492
+ ),
2493
+ ...(visibleSteering?.clientEventId
2494
+ ? [
2495
+ {
2496
+ clientEventId: visibleSteering.clientEventId,
2497
+ delivery: "steer" as const,
2498
+ destination: "chat" as const,
2499
+ text: visibleSteering.text,
2500
+ annotations:
2501
+ pendingOperationRef.current?.delivery === "steer"
2502
+ ? pendingOperationRef.current.annotationsAtSend
2503
+ : [],
2504
+ resources:
2505
+ pendingOperationRef.current?.delivery === "steer"
2506
+ ? pendingOperationRef.current.resourcesAtSend
2507
+ : [],
2508
+ occurredAt: steeringOccurredAtRef.current,
2509
+ state:
2510
+ visibleSteering.phase === "submitting"
2511
+ ? ("sending" as const)
2512
+ : visibleSteering.phase === "failed"
2513
+ ? ("failed" as const)
2514
+ : ("queued" as const),
2515
+ turnId: visibleSteering.turnId,
2516
+ triggerEventId: visibleSteering.triggerEventId,
2517
+ ...(visibleSteering.error ? { error: visibleSteering.error } : {}),
2518
+ ...(visibleSteering.outcomeUnknown !== undefined
2519
+ ? { outcomeUnknown: visibleSteering.outcomeUnknown }
2520
+ : {}),
2521
+ },
2522
+ ]
2523
+ : []),
2524
+ ]
2525
+ : [];
2033
2526
  const reloadDraft = useCallback(async () => await loadDraft(true), [loadDraft]);
2034
2527
  const clearError = useCallback(() => {
2035
2528
  if (targetKeyRef.current !== targetKey) return;
@@ -2048,27 +2541,23 @@ export function useComposer(
2048
2541
  clearAnnotationReviewTarget,
2049
2542
  hasDraftContent,
2050
2543
  send,
2051
- optimisticMessages: identityMatches
2052
- ? optimisticSends.map(
2053
- ({ input: _input, draftPayload: _payload, canRetry: _retry, ...item }) => item,
2054
- )
2055
- : [],
2544
+ optimisticMessages: visibleOptimisticMessages,
2056
2545
  retryOptimisticMessage,
2057
2546
  removeOptimisticMessage,
2058
2547
  steer,
2059
2548
  steering: visibleSteering,
2060
2549
  stoppingAttempt: identityMatches
2061
- ? controlStopping
2062
- ? "current"
2063
- : visibleSteering?.phase === "accepted" && visibleSteering.stoppingPreviousAttempt === true
2064
- ? "previous"
2065
- : null
2550
+ ? visibleSteering?.phase === "accepted" && visibleSteering.stoppingPreviousAttempt === true
2551
+ ? "previous"
2552
+ : null
2066
2553
  : null,
2067
2554
  sending: identityMatches ? sending : false,
2068
2555
  canSend:
2069
2556
  identityMatches &&
2070
2557
  Boolean(sessionId) &&
2071
2558
  !sending &&
2559
+ !draftLoading &&
2560
+ policy !== null &&
2072
2561
  sendBlockedRef.current?.() !== true &&
2073
2562
  annotationsComplete &&
2074
2563
  (hasPendingOperation ||
@@ -2080,11 +2569,16 @@ export function useComposer(
2080
2569
  resume,
2081
2570
  resumeScope,
2082
2571
  resuming: identityMatches ? resuming : false,
2572
+ effectiveControl: identityMatches ? effectiveControlRef.current : null,
2083
2573
  draft: identityMatches ? draft : null,
2084
2574
  draftRevision: identityMatches ? (draft?.revision ?? 0) : 0,
2085
2575
  draftLoading: identityMatches ? draftLoading : Boolean(sessionId) && durableDrafts,
2086
2576
  draftSaving: identityMatches ? draftSaving : false,
2087
2577
  draftConflict: identityMatches ? draftConflict : null,
2578
+ policy: identityMatches ? policy : null,
2579
+ setModel: updateModel,
2580
+ setReasoningEffort: updateReasoningEffort,
2581
+ setLatencyMode: updateLatencyMode,
2088
2582
  draftPersistence: durableDrafts ? "durable" : "disabled",
2089
2583
  applyDraft,
2090
2584
  reloadDraft,
@@ -2160,58 +2654,24 @@ function isOutcomeUnknownError(cause: unknown): boolean {
2160
2654
  );
2161
2655
  }
2162
2656
 
2657
+ async function replayOutcomeUnknown<T>(command: () => Promise<T>): Promise<T> {
2658
+ try {
2659
+ return await command();
2660
+ } catch (cause) {
2661
+ if (!isOutcomeUnknownError(cause)) throw cause;
2662
+ return await command();
2663
+ }
2664
+ }
2665
+
2163
2666
  function asError(cause: unknown): Error {
2164
2667
  return cause instanceof Error ? cause : new Error(String(cause));
2165
2668
  }
2166
2669
 
2167
2670
  function isDraftConflictError(error: Error): boolean {
2168
2671
  const apiError = error as Partial<OpenGeniApiError>;
2169
- if (apiError.status !== 409 || apiError.outcomeUnknown === true) return false;
2170
- // Production queue OCC returns `DRAFT_CHANGED`. Older/SDK-shaped 409s may
2171
- // omit code or use the generic conflict labels — all are recoverable OCC.
2172
- const code = apiError.code;
2173
- if (
2174
- code === undefined ||
2175
- code === "DRAFT_CHANGED" ||
2176
- code === "conflict" ||
2177
- code === "idempotency_conflict"
2178
- ) {
2179
- return true;
2180
- }
2181
- return /draft changed/i.test(error.message);
2182
- }
2183
-
2184
- /**
2185
- * One OCC retry: adopt the server revision and rewrite the same local content.
2186
- * Covers the common "tab slept through a successful autosave" case without
2187
- * stranding the operator on a raw 409 toast.
2188
- */
2189
- async function saveComposerDraftWithStaleRetry(input: {
2190
- client: {
2191
- getComposerDraft: (workspaceId: string, sessionId: string) => Promise<ComposerDraft>;
2192
- saveComposerDraft: (
2193
- workspaceId: string,
2194
- sessionId: string,
2195
- request: SaveComposerDraftRequest,
2196
- ) => Promise<ComposerDraft>;
2197
- };
2198
- workspaceId: string;
2199
- sessionId: string;
2200
- request: SaveComposerDraftRequest;
2201
- onAdoptRemote: (remote: ComposerDraft) => void;
2202
- }): Promise<ComposerDraft> {
2203
- try {
2204
- return await input.client.saveComposerDraft(input.workspaceId, input.sessionId, input.request);
2205
- } catch (cause) {
2206
- const problem = asError(cause);
2207
- if (!isDraftConflictError(problem)) throw problem;
2208
- const remote = await input.client.getComposerDraft(input.workspaceId, input.sessionId);
2209
- input.onAdoptRemote(remote);
2210
- return await input.client.saveComposerDraft(input.workspaceId, input.sessionId, {
2211
- ...input.request,
2212
- expectedRevision: remote.revision,
2213
- });
2214
- }
2672
+ return (
2673
+ apiError.status === 409 && apiError.outcomeUnknown !== true && apiError.code === "DRAFT_CHANGED"
2674
+ );
2215
2675
  }
2216
2676
 
2217
2677
  function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
@@ -2222,7 +2682,15 @@ function draftPayload(draft: ComposerDraft): SaveComposerDraftRequest {
2222
2682
  annotations: draft.annotations ?? [],
2223
2683
  model: draft.model,
2224
2684
  reasoningEffort: draft.reasoningEffort,
2225
- latencyMode: draft.latencyMode ?? "standard",
2685
+ latencyMode: draft.latencyMode,
2686
+ };
2687
+ }
2688
+
2689
+ function policyFromDraft(draft: ComposerDraft): ComposerPolicy {
2690
+ return {
2691
+ model: draft.model,
2692
+ reasoningEffort: draft.reasoningEffort,
2693
+ latencyMode: draft.latencyMode,
2226
2694
  };
2227
2695
  }
2228
2696
 
@@ -2231,16 +2699,17 @@ function composerDraftPayload(
2231
2699
  text: string,
2232
2700
  restoredResources: ResourceRef[],
2233
2701
  annotations: DraftTimelineAnnotation[],
2234
- extras: ComposerSendExtras,
2702
+ policy: ComposerPolicy,
2703
+ additionalResources: ResourceRef[],
2235
2704
  ): SaveComposerDraftRequest {
2236
2705
  return {
2237
2706
  expectedRevision: base.revision,
2238
2707
  text,
2239
- resources: mergeResources(restoredResources, extras.resources ?? []),
2708
+ resources: mergeResources(restoredResources, additionalResources),
2240
2709
  annotations,
2241
- model: extras.model ?? base.model,
2242
- reasoningEffort: extras.reasoningEffort ?? base.reasoningEffort,
2243
- latencyMode: extras.latencyMode ?? base.latencyMode ?? "standard",
2710
+ model: policy.model,
2711
+ reasoningEffort: policy.reasoningEffort,
2712
+ latencyMode: policy.latencyMode,
2244
2713
  };
2245
2714
  }
2246
2715