@alia.onl/sdk 22.1.0 → 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,13 @@
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
+
3
11
  ## 22.1.0
4
12
 
5
13
  - Stop explicitly cancels the authenticated foreground turn across API replicas;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "22.1.0",
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",
@@ -216,6 +216,8 @@ export function useConversationStream({
216
216
  const queryClient = useQueryClient();
217
217
  const abortControllerRef = useRef<AbortController | null>(null);
218
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);
219
221
 
220
222
  // Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
221
223
  const pendingContentRef = useRef('');
@@ -279,6 +281,8 @@ export function useConversationStream({
279
281
  mountedRef.current = true;
280
282
  return () => {
281
283
  mountedRef.current = false;
284
+ turnGenerationRef.current++;
285
+ turnControlRef.current = null;
282
286
  flushPendingUpdates();
283
287
  if (flushTimerRef.current) {
284
288
  clearTimeout(flushTimerRef.current);
@@ -324,6 +328,8 @@ export function useConversationStream({
324
328
  const was = conversationRef.current;
325
329
  conversationRef.current = conversationId;
326
330
  if (was !== undefined && was !== conversationId) {
331
+ turnGenerationRef.current++;
332
+ turnControlRef.current = null;
327
333
  hostOutcomesRef.current.clear();
328
334
  // The reply still streaming belongs to the thread that was left: cut it, drop what it had
329
335
  // batched (it would land on this thread's last message) and let the screen be idle.
@@ -350,6 +356,8 @@ export function useConversationStream({
350
356
  const was = accountRef.current;
351
357
  accountRef.current = accountId;
352
358
  if (was === null || was === accountId) return;
359
+ turnGenerationRef.current++;
360
+ turnControlRef.current = null;
353
361
  abortControllerRef.current?.abort();
354
362
  abortControllerRef.current = null;
355
363
  dropPendingUpdates();
@@ -367,6 +375,7 @@ export function useConversationStream({
367
375
 
368
376
  const append = useCallback(
369
377
  async (message: Omit<Message, 'id'>, options?: SendOptions): Promise<SendOutcome> => {
378
+ const turnGeneration = ++turnGenerationRef.current;
370
379
  setIsLoading(true);
371
380
  setError(null);
372
381
  // A new send answers the old failure, whether it is the retry or a fresh
@@ -1376,33 +1385,31 @@ export function useConversationStream({
1376
1385
  setError(finalError);
1377
1386
  return keepFailedTurn(true, finalError.message);
1378
1387
  } finally {
1379
- // Flush any remaining batched content
1380
- flushPendingUpdates();
1381
- if (flushTimerRef.current) {
1382
- clearTimeout(flushTimerRef.current);
1383
- 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
+ }
1384
1396
  }
1385
- // Only the turn that still owns the ref clears it and the loading flag: after Stop and a new
1386
- // send, this `finally` belongs to the old turn and must not idle the new one.
1387
- const ownsTurn =
1388
- abortControllerRef.current === null || abortControllerRef.current === controller;
1389
- if (abortControllerRef.current === controller) abortControllerRef.current = null;
1390
- if (turnControlRef.current === turnControl) turnControlRef.current = null;
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.
1399
+ settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
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.
1391
1402
  if (turnControl) await turnControl.finish();
1392
1403
  else linked?.dispose();
1393
- // A turn that is still streaming here ended without an error path
1394
- // settling it: on its own, or because `stop()` aborted the controller.
1395
- // (The error paths abort it too, but they have settled it first.)
1396
- settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
1397
- if (ownsTurn) setIsLoading(false);
1398
- /*
1399
- * The answer replaces your own line in the sidebar — once per turn rather
1400
- * than once per token. Read from what this turn received, not from the
1401
- * rendered messages, which have not caught up with the last batched flush.
1402
- */
1403
- if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1404
- // Every way a turn stops streaming — finished, stopped, failed — passes here once.
1405
- if (mountedRef.current) host.onTurnEnd?.();
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
+ }
1406
1413
  }
1407
1414
  },
1408
1415
  [
@@ -1434,9 +1441,14 @@ export function useConversationStream({
1434
1441
 
1435
1442
  const stop = useCallback(() => {
1436
1443
  const turn = turnControlRef.current;
1444
+ const turnGeneration = turnGenerationRef.current;
1437
1445
  if (!turn) return;
1438
1446
  void turn.stop().catch((error: unknown) => {
1439
- if (turnControlRef.current === turn && mountedRef.current) {
1447
+ if (
1448
+ turnControlRef.current === turn &&
1449
+ turnGenerationRef.current === turnGeneration &&
1450
+ mountedRef.current
1451
+ ) {
1440
1452
  setError(error instanceof Error ? error : new Error('Generation could not be stopped.'));
1441
1453
  }
1442
1454
  });