@alia.onl/sdk 22.0.1 → 22.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 22.1.1
4
+
5
+ - A delayed Stop acknowledgement cannot idle a newer turn, replace its sidebar
6
+ preview, or notify a different conversation. Thread/account changes and unmounts
7
+ invalidate old completion callbacks while the original acknowledgement drains.
8
+ - Keep repeated Stop requests deduplicated during that drain; errors from an old
9
+ Stop cannot appear in a replacement thread. Public APIs and peer ranges are unchanged.
10
+
11
+ ## 22.1.0
12
+
13
+ - Stop explicitly cancels the authenticated foreground turn across API replicas;
14
+ closing a sheet or losing its transport still allows background completion.
15
+ - Export `createChatTurnControl` / `AliaChatTurnControl` from `@alia.onl/sdk/runtime`
16
+ for custom Stop controls, with account-change fences and early-turn ids.
17
+ - Requires the explicit Stop API receiver to be deployed before adoption; see
18
+ `docs/explicit-chat-stop.md` in the repository.
19
+
20
+
3
21
  ## 22.0.1
4
22
 
5
23
  Remove the Bloom peer upper bound to follow the first-party package policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "22.0.1",
3
+ "version": "22.1.1",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -265,6 +265,8 @@ export interface AliaChatContentProps {
265
265
  presentedProgress?: SharedValue<number>;
266
266
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
267
267
  onStopReady?: (stop: (() => void) | null) => void;
268
+ /** Detach a closed view while its durable answer continues. Never invokes explicit Stop. */
269
+ onDetachReady?: (detach: (() => void) | null) => void;
268
270
  }
269
271
 
270
272
  /** No live conversation while the thread looks at a moment of its past, as ONE array. */
@@ -319,6 +321,7 @@ function AliaConversationView({
319
321
  initialTurn,
320
322
  presentedProgress,
321
323
  onStopReady,
324
+ onDetachReady,
322
325
  }: ConversationViewProps) {
323
326
  const ownFlights = useAvatarFlights();
324
327
  const flights = sharedFlights ?? ownFlights;
@@ -359,6 +362,7 @@ function AliaConversationView({
359
362
  error,
360
363
  clearError,
361
364
  stopGeneration: stop,
365
+ detachGeneration: detach,
362
366
  startOver,
363
367
  clearConversation,
364
368
  suggestedNewConversation,
@@ -384,6 +388,10 @@ function AliaConversationView({
384
388
  onStopReady?.(stop);
385
389
  return () => onStopReady?.(null);
386
390
  }, [onStopReady, stop]);
391
+ useEffect(() => {
392
+ onDetachReady?.(detach);
393
+ return () => onDetachReady?.(null);
394
+ }, [onDetachReady, detach]);
387
395
 
388
396
  // ── What the person does: write, send, rewrite, call ──
389
397
  // An agent's starters are its welcome suggestions, unless the host brought its own.
@@ -151,9 +151,9 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
151
151
  const conversationProgress = useSharedValue(0);
152
152
  const mainRef = useRef<BottomSheetRef>(null);
153
153
  const conversationRef = useRef<BottomSheetRef>(null);
154
- // Each sheet's chat registers its own canceller; closing a sheet stops its own turn.
155
- const mainStopRef = useRef<(() => void) | null>(null);
156
- const conversationStopRef = useRef<(() => void) | null>(null);
154
+ // Closing detaches this sheet's reader; only its explicit Stop cancels the generation.
155
+ const mainDetachRef = useRef<(() => void) | null>(null);
156
+ const conversationDetachRef = useRef<(() => void) | null>(null);
157
157
 
158
158
  // The conversation sheet, stacked over the main one while a chat is open: begun by a first
159
159
  // turn, or a stored one picked up again. The key makes every opening a fresh chat.
@@ -163,11 +163,11 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
163
163
  conversationId?: string;
164
164
  } | null>(null);
165
165
 
166
- const registerMainStop = useCallback((stop: (() => void) | null) => {
167
- mainStopRef.current = stop;
166
+ const registerMainDetach = useCallback((stop: (() => void) | null) => {
167
+ mainDetachRef.current = stop;
168
168
  }, []);
169
- const registerConversationStop = useCallback((stop: (() => void) | null) => {
170
- conversationStopRef.current = stop;
169
+ const registerConversationDetach = useCallback((stop: (() => void) | null) => {
170
+ conversationDetachRef.current = stop;
171
171
  }, []);
172
172
 
173
173
  const startConversation = useCallback((turn: AliaTurn) => {
@@ -186,11 +186,11 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
186
186
 
187
187
  // Back, and the menu's Clear: close the conversation, back to the main screen underneath.
188
188
  const closeConversation = useCallback(() => {
189
- conversationStopRef.current?.();
189
+ conversationDetachRef.current?.();
190
190
  conversationRef.current?.dismiss();
191
191
  }, []);
192
192
  const onConversationDismissAttempt = useCallback(() => {
193
- conversationStopRef.current?.();
193
+ conversationDetachRef.current?.();
194
194
  return true;
195
195
  }, []);
196
196
 
@@ -214,8 +214,8 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
214
214
  );
215
215
 
216
216
  const handleDismiss = useCallback(() => {
217
- conversationStopRef.current?.();
218
- mainStopRef.current?.();
217
+ conversationDetachRef.current?.();
218
+ mainDetachRef.current?.();
219
219
  conversationRef.current?.dismiss();
220
220
  mainRef.current?.dismiss();
221
221
  closeThought();
@@ -239,8 +239,8 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
239
239
 
240
240
  // Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
241
241
  const onMainDismissAttempt = useCallback(() => {
242
- mainStopRef.current?.();
243
- conversationStopRef.current?.();
242
+ mainDetachRef.current?.();
243
+ conversationDetachRef.current?.();
244
244
  conversationRef.current?.dismiss();
245
245
  closeThought();
246
246
  return true;
@@ -324,7 +324,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
324
324
  ) : undefined
325
325
  }
326
326
  presentedProgress={mainProgress}
327
- onStopReady={registerMainStop}
327
+ onDetachReady={registerMainDetach}
328
328
  onStart={startConversation}
329
329
  />
330
330
  </BottomSheet>
@@ -347,7 +347,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
347
347
  conversationId={conversation.conversationId}
348
348
  onConversationChange={followConversation}
349
349
  presentedProgress={conversationProgress}
350
- onStopReady={registerConversationStop}
350
+ onDetachReady={registerConversationDetach}
351
351
  chrome={conversationChrome}
352
352
  />
353
353
  </BottomSheet>
@@ -0,0 +1,74 @@
1
+ import type { OxyServices } from '@oxy.so/core';
2
+ import { createRandomUuid } from './random-uuid';
3
+ import { awaitTurnPreparation } from './turn-preparation';
4
+
5
+ type LinkedClient = ReturnType<OxyServices['createLinkedClient']>;
6
+
7
+ /** One client turn, created before any asynchronous preparation or metadata. */
8
+ export function createChatTurnControl(
9
+ linked: LinkedClient,
10
+ headers: Promise<Record<string, string>>,
11
+ conversationId: string | undefined,
12
+ controller: AbortController,
13
+ ) {
14
+ // A host can reject before Stop is pressed. Observe that rejection immediately;
15
+ // awaiting the original promise below still reports the failure to Stop.
16
+ void headers.catch(() => undefined);
17
+ const id = createRandomUuid();
18
+ let stopping: Promise<void> | undefined;
19
+ let ended = false;
20
+ const lifetime = new AbortController();
21
+ const epoch = linked.client.getSessionEpoch();
22
+ const current = () => linked.client.getSessionEpoch() === epoch;
23
+ const unsubscribe = linked.client.addTokenChangeListener(() => {
24
+ if (!current()) {
25
+ const reason = new Error('The conversation account changed');
26
+ lifetime.abort(reason);
27
+ controller.abort(reason);
28
+ }
29
+ });
30
+ return {
31
+ id,
32
+ /** Only a deliberate Stop invokes this. View teardown merely aborts its transport. */
33
+ stop(): Promise<void> {
34
+ if (stopping) return stopping;
35
+ if (ended || controller.signal.aborted) return Promise.resolve();
36
+ stopping = (async () => {
37
+ const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(10_000)]);
38
+ const assertionHeaders = await awaitTurnPreparation(headers, signal);
39
+ signal.throwIfAborted();
40
+ controller.signal.throwIfAborted();
41
+ if (!current()) throw new Error('The conversation account changed');
42
+ const response = await linked.client.requestAuthenticatedResponse({
43
+ url: `/v1/chat/turns/${id}/cancel`,
44
+ method: 'POST',
45
+ headers: { ...assertionHeaders, 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({ ...(conversationId ? { conversationId } : {}) }),
47
+ // The cancelled chat signal must NOT cancel its own control request.
48
+ signal,
49
+ });
50
+ if (!response.ok) {
51
+ await response.body?.cancel();
52
+ throw new Error('Generation could not be stopped. Try again.');
53
+ }
54
+ await response.body?.cancel();
55
+ controller.abort();
56
+ })().catch((error) => {
57
+ stopping = undefined;
58
+ throw error;
59
+ });
60
+ return stopping;
61
+ },
62
+ async finish() {
63
+ ended = true;
64
+ // The stream can close before the replica answering Stop sends its acknowledgement.
65
+ await stopping?.catch(() => undefined);
66
+ lifetime.abort();
67
+ unsubscribe();
68
+ linked.dispose();
69
+ },
70
+ };
71
+ }
72
+
73
+ /** Public lifecycle handle for hosts that render their own Stop control. */
74
+ export type AliaChatTurnControl = ReturnType<typeof createChatTurnControl>;
@@ -112,3 +112,5 @@ export type { BuiltMessageContent, DroppedAttachment, UploadDocument } from './a
112
112
 
113
113
  // The conversation as data (also the leaf entry `@alia.onl/sdk/model`).
114
114
  export * from './model';
115
+
116
+ export { createChatTurnControl, type AliaChatTurnControl } from './chat-turn-control';
@@ -0,0 +1,21 @@
1
+ /** Fence asynchronous host preparation without waiting for an uncooperative host. */
2
+ export function awaitTurnPreparation<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
3
+ return new Promise<T>((resolve, reject) => {
4
+ const aborted = () => {
5
+ signal.removeEventListener('abort', aborted);
6
+ reject(signal.reason);
7
+ };
8
+ pending.then(
9
+ (value) => {
10
+ signal.removeEventListener('abort', aborted);
11
+ resolve(value);
12
+ },
13
+ (error) => {
14
+ signal.removeEventListener('abort', aborted);
15
+ reject(error);
16
+ },
17
+ );
18
+ if (signal.aborted) aborted();
19
+ else signal.addEventListener('abort', aborted, { once: true });
20
+ });
21
+ }
@@ -15,6 +15,8 @@ import {
15
15
  import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
16
16
  import { aliaQueryKeys } from './query-keys';
17
17
  import { createRandomUuid } from './random-uuid';
18
+ import { createChatTurnControl } from './chat-turn-control';
19
+ import { awaitTurnPreparation } from './turn-preparation';
18
20
  import { type AliaHostTools, HostOutcomeQueue, hostRequest } from './host-tools';
19
21
  import { API_ROUTES } from './routes';
20
22
  import { createSseFrameReader } from './sse-frame-reader';
@@ -213,6 +215,9 @@ export function useConversationStream({
213
215
  const appAssertionHeaders = useAppAssertionHeaders();
214
216
  const queryClient = useQueryClient();
215
217
  const abortControllerRef = useRef<AbortController | null>(null);
218
+ const turnControlRef = useRef<ReturnType<typeof createChatTurnControl> | null>(null);
219
+ // A Stop acknowledgement can outlive its stream, thread, or the next send.
220
+ const turnGenerationRef = useRef(0);
216
221
 
217
222
  // Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
218
223
  const pendingContentRef = useRef('');
@@ -276,6 +281,8 @@ export function useConversationStream({
276
281
  mountedRef.current = true;
277
282
  return () => {
278
283
  mountedRef.current = false;
284
+ turnGenerationRef.current++;
285
+ turnControlRef.current = null;
279
286
  flushPendingUpdates();
280
287
  if (flushTimerRef.current) {
281
288
  clearTimeout(flushTimerRef.current);
@@ -321,6 +328,8 @@ export function useConversationStream({
321
328
  const was = conversationRef.current;
322
329
  conversationRef.current = conversationId;
323
330
  if (was !== undefined && was !== conversationId) {
331
+ turnGenerationRef.current++;
332
+ turnControlRef.current = null;
324
333
  hostOutcomesRef.current.clear();
325
334
  // The reply still streaming belongs to the thread that was left: cut it, drop what it had
326
335
  // batched (it would land on this thread's last message) and let the screen be idle.
@@ -347,6 +356,8 @@ export function useConversationStream({
347
356
  const was = accountRef.current;
348
357
  accountRef.current = accountId;
349
358
  if (was === null || was === accountId) return;
359
+ turnGenerationRef.current++;
360
+ turnControlRef.current = null;
350
361
  abortControllerRef.current?.abort();
351
362
  abortControllerRef.current = null;
352
363
  dropPendingUpdates();
@@ -364,6 +375,7 @@ export function useConversationStream({
364
375
 
365
376
  const append = useCallback(
366
377
  async (message: Omit<Message, 'id'>, options?: SendOptions): Promise<SendOutcome> => {
378
+ const turnGeneration = ++turnGenerationRef.current;
367
379
  setIsLoading(true);
368
380
  setError(null);
369
381
  // A new send answers the old failure, whether it is the retry or a fresh
@@ -615,6 +627,7 @@ export function useConversationStream({
615
627
  /** The request's own controller: `finally` asks it whether the person stopped the turn. */
616
628
  let controller: AbortController | null = null;
617
629
  let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
630
+ let turnControl: ReturnType<typeof createChatTurnControl> | null = null;
618
631
  /** Cut THIS turn's stream. Never the ref's: by now it may hold a later turn's controller. */
619
632
  const abortThisTurn = (): void => {
620
633
  controller?.abort();
@@ -626,9 +639,17 @@ export function useConversationStream({
626
639
  // conversation change reach a turn that is still collecting device info.
627
640
  controller = new AbortController();
628
641
  abortControllerRef.current = controller;
629
-
630
- // Collect device info (will be available to AI via tool if needed)
631
- const deviceInfo = host.deviceInfo ? await host.deviceInfo() : undefined;
642
+ linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
643
+ const assertion = appAssertionHeaders();
644
+ turnControl = createChatTurnControl(linked, assertion, conversationId, controller);
645
+ turnControlRef.current = turnControl;
646
+
647
+ // Attach both rejection handlers immediately; a slow host must not leave
648
+ // an already rejected surface assertion unobserved or defeat detachment.
649
+ const [appAssertion, deviceInfo] = await awaitTurnPreparation(
650
+ Promise.all([assertion, host.deviceInfo ? host.deviceInfo() : undefined]),
651
+ controller.signal,
652
+ );
632
653
  if (controller.signal.aborted) return 'aborted';
633
654
 
634
655
  /**
@@ -646,8 +667,7 @@ export function useConversationStream({
646
667
  // Authentication, the preflight refresh and the single 401 replay belong
647
668
  // to the linked Oxy client; this layer never reads or writes a bearer.
648
669
  // Which Oxy app this turn is in (ADR 0021): an agent Oxy confines to an app opens only there.
649
- const appAssertion = await appAssertionHeaders();
650
- linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
670
+ if (controller.signal.aborted) return 'aborted';
651
671
  const response = await linked.client.requestAuthenticatedResponse({
652
672
  method: 'POST',
653
673
  url: API_ROUTES.chat,
@@ -658,6 +678,7 @@ export function useConversationStream({
658
678
  ...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
659
679
  },
660
680
  body: JSON.stringify({
681
+ turnId: turnControl.id,
661
682
  messages: messagesToSend,
662
683
  // The reply is stored under the id it is drawn under here, so a vote
663
684
  // or a read-aloud on it needs no reload to find it.
@@ -688,6 +709,12 @@ export function useConversationStream({
688
709
  const lockedFeature = lockedFeatureOf(
689
710
  errorData?.error && typeof errorData.error === 'object' ? errorData.error : null,
690
711
  );
712
+ if (errorCode(errorData?.error) === 'turn_cancelled') {
713
+ flushPendingUpdates();
714
+ settleAssistant('cancelled');
715
+ abortThisTurn();
716
+ return 'aborted';
717
+ }
691
718
  if (lockedFeature !== null) {
692
719
  host.onFeatureLocked?.(lockedFeature);
693
720
  return rollback();
@@ -1037,6 +1064,13 @@ export function useConversationStream({
1037
1064
  // Handle structured error events sent via SSE
1038
1065
  if (parsed.error) {
1039
1066
  const err = parsed.error;
1067
+ if (errorCode(err) === 'turn_cancelled') {
1068
+ flushPendingUpdates();
1069
+ settleAssistant('cancelled');
1070
+ abortThisTurn();
1071
+ reader.cancel().catch(() => {});
1072
+ return 'aborted';
1073
+ }
1040
1074
  // An agent's feature gate refused the turn before anything was spent: the host
1041
1075
  // shows its own upsell, and the person's words go back to the composer.
1042
1076
  const lockedFeature = lockedFeatureOf(err);
@@ -1351,31 +1385,31 @@ export function useConversationStream({
1351
1385
  setError(finalError);
1352
1386
  return keepFailedTurn(true, finalError.message);
1353
1387
  } finally {
1354
- // Flush any remaining batched content
1355
- flushPendingUpdates();
1356
- if (flushTimerRef.current) {
1357
- clearTimeout(flushTimerRef.current);
1358
- flushTimerRef.current = null;
1388
+ // Batches belong to the current turn. An older finally must not flush or clear
1389
+ // the next turn's shared timer, even when its controller ref has been cleared.
1390
+ if (turnGenerationRef.current === turnGeneration) {
1391
+ flushPendingUpdates();
1392
+ if (flushTimerRef.current) {
1393
+ clearTimeout(flushTimerRef.current);
1394
+ flushTimerRef.current = null;
1395
+ }
1359
1396
  }
1360
- // Only the turn that still owns the ref clears it and the loading flag: after Stop and a new
1361
- // send, this `finally` belongs to the old turn and must not idle the new one.
1362
- const ownsTurn =
1363
- abortControllerRef.current === null || abortControllerRef.current === controller;
1364
- if (abortControllerRef.current === controller) abortControllerRef.current = null;
1365
- linked?.dispose();
1366
- // A turn that is still streaming here ended without an error path
1367
- // settling it: on its own, or because `stop()` aborted the controller.
1368
- // (The error paths abort it too, but they have settled it first.)
1397
+ // Settle only this turn's message ids, including when a new send has already
1398
+ // started in the same conversation. Error paths may have settled them first.
1369
1399
  settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
1370
- if (ownsTurn) setIsLoading(false);
1371
- /*
1372
- * The answer replaces your own line in the sidebar — once per turn rather
1373
- * than once per token. Read from what this turn received, not from the
1374
- * rendered messages, which have not caught up with the last batched flush.
1375
- */
1376
- if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1377
- // Every way a turn stops streaming — finished, stopped, failed — passes here once.
1378
- if (mountedRef.current) host.onTurnEnd?.();
1400
+ // Retain this control while its ACK drains so repeated Stop remains deduplicated.
1401
+ // Local transport cleanup is required even after the thread has been replaced.
1402
+ if (turnControl) await turnControl.finish();
1403
+ else linked?.dispose();
1404
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
1405
+ if (turnControlRef.current === turnControl) turnControlRef.current = null;
1406
+ // Recheck after the await: another turn, thread, account or mount may now own UI.
1407
+ if (mountedRef.current && turnGenerationRef.current === turnGeneration) {
1408
+ setIsLoading(false);
1409
+ // The sidebar preview and completion notification belong to this visible turn.
1410
+ if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1411
+ host.onTurnEnd?.();
1412
+ }
1379
1413
  }
1380
1414
  },
1381
1415
  [
@@ -1397,7 +1431,7 @@ export function useConversationStream({
1397
1431
  ],
1398
1432
  );
1399
1433
 
1400
- const stop = useCallback(() => {
1434
+ const detach = useCallback(() => {
1401
1435
  if (abortControllerRef.current) {
1402
1436
  abortControllerRef.current.abort();
1403
1437
  abortControllerRef.current = null;
@@ -1405,6 +1439,21 @@ export function useConversationStream({
1405
1439
  setIsLoading(false);
1406
1440
  }, []);
1407
1441
 
1442
+ const stop = useCallback(() => {
1443
+ const turn = turnControlRef.current;
1444
+ const turnGeneration = turnGenerationRef.current;
1445
+ if (!turn) return;
1446
+ void turn.stop().catch((error: unknown) => {
1447
+ if (
1448
+ turnControlRef.current === turn &&
1449
+ turnGenerationRef.current === turnGeneration &&
1450
+ mountedRef.current
1451
+ ) {
1452
+ setError(error instanceof Error ? error : new Error('Generation could not be stopped.'));
1453
+ }
1454
+ });
1455
+ }, []);
1456
+
1408
1457
  const clearError = useCallback(() => setError(null), []);
1409
1458
 
1410
1459
  /**
@@ -1451,6 +1500,7 @@ export function useConversationStream({
1451
1500
  error,
1452
1501
  append,
1453
1502
  stop,
1503
+ detach,
1454
1504
  setMessages: setMessagesAndRef,
1455
1505
  conversationTitle,
1456
1506
  clearError,
@@ -134,6 +134,7 @@ export function useConversationThread({
134
134
  clearError,
135
135
  setMessages,
136
136
  stop,
137
+ detach,
137
138
  suggestedNewConversation,
138
139
  dismissSuggestedNewConversation,
139
140
  agentRun,
@@ -547,6 +548,7 @@ export function useConversationThread({
547
548
  promptOf,
548
549
  turnOptionsOf,
549
550
  stopGeneration,
551
+ detachGeneration: detach,
550
552
  clearConversation,
551
553
  clearError,
552
554
  setMessages,