@alia.onl/sdk 22.0.0 → 22.1.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 22.1.0
4
+
5
+ - Stop explicitly cancels the authenticated foreground turn across API replicas;
6
+ closing a sheet or losing its transport still allows background completion.
7
+ - Export `createChatTurnControl` / `AliaChatTurnControl` from `@alia.onl/sdk/runtime`
8
+ for custom Stop controls, with account-change fences and early-turn ids.
9
+ - Requires the explicit Stop API receiver to be deployed before adoption; see
10
+ `docs/explicit-chat-stop.md` in the repository.
11
+
12
+
13
+ ## 22.0.1
14
+
15
+ Remove the Bloom peer upper bound to follow the first-party package policy.
16
+ The required minimum remains `15.10.1`; core `>=7.1.0 <8` and services
17
+ `>=13.0.0 <14` are unchanged. This patch changes package metadata only.
18
+
3
19
  ## 22.0.0
4
20
 
5
21
  Requires `@oxy.so/core >=7.1.0 <8` and `@oxy.so/services >=13.0.0 <14`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "22.0.0",
3
+ "version": "22.1.0",
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",
@@ -54,7 +54,7 @@
54
54
  "react": "^19.2.3",
55
55
  "react-native": "^0.86.0 || ^0.88.0",
56
56
  "@expo/vector-icons": "^15.1.1",
57
- "@oxy.so/bloom": ">=15.10.1 <16.0.0",
57
+ "@oxy.so/bloom": ">=15.10.1",
58
58
  "@oxy.so/core": ">=7.1.0 <8",
59
59
  "@oxy.so/services": ">=13.0.0 <14",
60
60
  "@tanstack/react-query": "^5.101",
@@ -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,7 @@ 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);
216
219
 
217
220
  // Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
218
221
  const pendingContentRef = useRef('');
@@ -615,6 +618,7 @@ export function useConversationStream({
615
618
  /** The request's own controller: `finally` asks it whether the person stopped the turn. */
616
619
  let controller: AbortController | null = null;
617
620
  let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
621
+ let turnControl: ReturnType<typeof createChatTurnControl> | null = null;
618
622
  /** Cut THIS turn's stream. Never the ref's: by now it may hold a later turn's controller. */
619
623
  const abortThisTurn = (): void => {
620
624
  controller?.abort();
@@ -626,9 +630,17 @@ export function useConversationStream({
626
630
  // conversation change reach a turn that is still collecting device info.
627
631
  controller = new AbortController();
628
632
  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;
633
+ linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
634
+ const assertion = appAssertionHeaders();
635
+ turnControl = createChatTurnControl(linked, assertion, conversationId, controller);
636
+ turnControlRef.current = turnControl;
637
+
638
+ // Attach both rejection handlers immediately; a slow host must not leave
639
+ // an already rejected surface assertion unobserved or defeat detachment.
640
+ const [appAssertion, deviceInfo] = await awaitTurnPreparation(
641
+ Promise.all([assertion, host.deviceInfo ? host.deviceInfo() : undefined]),
642
+ controller.signal,
643
+ );
632
644
  if (controller.signal.aborted) return 'aborted';
633
645
 
634
646
  /**
@@ -646,8 +658,7 @@ export function useConversationStream({
646
658
  // Authentication, the preflight refresh and the single 401 replay belong
647
659
  // to the linked Oxy client; this layer never reads or writes a bearer.
648
660
  // 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 });
661
+ if (controller.signal.aborted) return 'aborted';
651
662
  const response = await linked.client.requestAuthenticatedResponse({
652
663
  method: 'POST',
653
664
  url: API_ROUTES.chat,
@@ -658,6 +669,7 @@ export function useConversationStream({
658
669
  ...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
659
670
  },
660
671
  body: JSON.stringify({
672
+ turnId: turnControl.id,
661
673
  messages: messagesToSend,
662
674
  // The reply is stored under the id it is drawn under here, so a vote
663
675
  // or a read-aloud on it needs no reload to find it.
@@ -688,6 +700,12 @@ export function useConversationStream({
688
700
  const lockedFeature = lockedFeatureOf(
689
701
  errorData?.error && typeof errorData.error === 'object' ? errorData.error : null,
690
702
  );
703
+ if (errorCode(errorData?.error) === 'turn_cancelled') {
704
+ flushPendingUpdates();
705
+ settleAssistant('cancelled');
706
+ abortThisTurn();
707
+ return 'aborted';
708
+ }
691
709
  if (lockedFeature !== null) {
692
710
  host.onFeatureLocked?.(lockedFeature);
693
711
  return rollback();
@@ -1037,6 +1055,13 @@ export function useConversationStream({
1037
1055
  // Handle structured error events sent via SSE
1038
1056
  if (parsed.error) {
1039
1057
  const err = parsed.error;
1058
+ if (errorCode(err) === 'turn_cancelled') {
1059
+ flushPendingUpdates();
1060
+ settleAssistant('cancelled');
1061
+ abortThisTurn();
1062
+ reader.cancel().catch(() => {});
1063
+ return 'aborted';
1064
+ }
1040
1065
  // An agent's feature gate refused the turn before anything was spent: the host
1041
1066
  // shows its own upsell, and the person's words go back to the composer.
1042
1067
  const lockedFeature = lockedFeatureOf(err);
@@ -1362,7 +1387,9 @@ export function useConversationStream({
1362
1387
  const ownsTurn =
1363
1388
  abortControllerRef.current === null || abortControllerRef.current === controller;
1364
1389
  if (abortControllerRef.current === controller) abortControllerRef.current = null;
1365
- linked?.dispose();
1390
+ if (turnControlRef.current === turnControl) turnControlRef.current = null;
1391
+ if (turnControl) await turnControl.finish();
1392
+ else linked?.dispose();
1366
1393
  // A turn that is still streaming here ended without an error path
1367
1394
  // settling it: on its own, or because `stop()` aborted the controller.
1368
1395
  // (The error paths abort it too, but they have settled it first.)
@@ -1397,7 +1424,7 @@ export function useConversationStream({
1397
1424
  ],
1398
1425
  );
1399
1426
 
1400
- const stop = useCallback(() => {
1427
+ const detach = useCallback(() => {
1401
1428
  if (abortControllerRef.current) {
1402
1429
  abortControllerRef.current.abort();
1403
1430
  abortControllerRef.current = null;
@@ -1405,6 +1432,16 @@ export function useConversationStream({
1405
1432
  setIsLoading(false);
1406
1433
  }, []);
1407
1434
 
1435
+ const stop = useCallback(() => {
1436
+ const turn = turnControlRef.current;
1437
+ if (!turn) return;
1438
+ void turn.stop().catch((error: unknown) => {
1439
+ if (turnControlRef.current === turn && mountedRef.current) {
1440
+ setError(error instanceof Error ? error : new Error('Generation could not be stopped.'));
1441
+ }
1442
+ });
1443
+ }, []);
1444
+
1408
1445
  const clearError = useCallback(() => setError(null), []);
1409
1446
 
1410
1447
  /**
@@ -1451,6 +1488,7 @@ export function useConversationStream({
1451
1488
  error,
1452
1489
  append,
1453
1490
  stop,
1491
+ detach,
1454
1492
  setMessages: setMessagesAndRef,
1455
1493
  conversationTitle,
1456
1494
  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,