@nocobase/app-plugin-agents 2.0.0-beta.0 → 2.0.0-beta.2

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 (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +9 -1
  3. package/dist/client/chat/coalesced-invalidation.d.ts +3 -8
  4. package/dist/client/chat/coalesced-invalidation.d.ts.map +1 -1
  5. package/dist/client/chat/coalesced-invalidation.js +16 -21
  6. package/dist/client/chat/coalesced-invalidation.js.map +1 -1
  7. package/dist/client/chat/controller.d.ts +30 -3
  8. package/dist/client/chat/controller.d.ts.map +1 -1
  9. package/dist/client/chat/controller.js +472 -76
  10. package/dist/client/chat/controller.js.map +1 -1
  11. package/dist/client/chat/provider.d.ts.map +1 -1
  12. package/dist/client/chat/provider.js +7 -4
  13. package/dist/client/chat/provider.js.map +1 -1
  14. package/dist/client/chat/use-chat.d.ts +4 -2
  15. package/dist/client/chat/use-chat.d.ts.map +1 -1
  16. package/dist/client/chat/use-chat.js +82 -51
  17. package/dist/client/chat/use-chat.js.map +1 -1
  18. package/dist/client/hooks/use-realtime-topic.d.ts +1 -1
  19. package/dist/client/hooks/use-realtime-topic.d.ts.map +1 -1
  20. package/dist/client/hooks/use-realtime-topic.js +8 -4
  21. package/dist/client/hooks/use-realtime-topic.js.map +1 -1
  22. package/dist/client/kit.d.ts.map +1 -1
  23. package/dist/client/kit.js +30 -5
  24. package/dist/client/kit.js.map +1 -1
  25. package/dist/client/locales/de-DE.d.ts.map +1 -1
  26. package/dist/client/locales/de-DE.js +2 -0
  27. package/dist/client/locales/de-DE.js.map +1 -1
  28. package/dist/client/locales/es-ES.d.ts.map +1 -1
  29. package/dist/client/locales/es-ES.js +2 -0
  30. package/dist/client/locales/es-ES.js.map +1 -1
  31. package/dist/client/locales/fr-FR.d.ts.map +1 -1
  32. package/dist/client/locales/fr-FR.js +2 -0
  33. package/dist/client/locales/fr-FR.js.map +1 -1
  34. package/dist/client/locales/id-ID.d.ts.map +1 -1
  35. package/dist/client/locales/id-ID.js +2 -0
  36. package/dist/client/locales/id-ID.js.map +1 -1
  37. package/dist/client/locales/ja-JP.d.ts.map +1 -1
  38. package/dist/client/locales/ja-JP.js +2 -0
  39. package/dist/client/locales/ja-JP.js.map +1 -1
  40. package/dist/client/locales/pt-BR.d.ts.map +1 -1
  41. package/dist/client/locales/pt-BR.js +2 -0
  42. package/dist/client/locales/pt-BR.js.map +1 -1
  43. package/dist/client/locales/runWait.en-US.d.ts +2 -0
  44. package/dist/client/locales/runWait.en-US.d.ts.map +1 -1
  45. package/dist/client/locales/runWait.en-US.js +2 -0
  46. package/dist/client/locales/runWait.en-US.js.map +1 -1
  47. package/dist/client/locales/runWait.zh-CN.d.ts.map +1 -1
  48. package/dist/client/locales/runWait.zh-CN.js +2 -0
  49. package/dist/client/locales/runWait.zh-CN.js.map +1 -1
  50. package/dist/client/locales/vi-VN.d.ts.map +1 -1
  51. package/dist/client/locales/vi-VN.js +2 -0
  52. package/dist/client/locales/vi-VN.js.map +1 -1
  53. package/dist/client/locales/zh-TW.d.ts.map +1 -1
  54. package/dist/client/locales/zh-TW.js +2 -0
  55. package/dist/client/locales/zh-TW.js.map +1 -1
  56. package/dist/client/pages/agents/agent-dialog.js +4 -1
  57. package/dist/client/pages/agents/agent-dialog.js.map +1 -1
  58. package/dist/client/runs/format-wait.d.ts.map +1 -1
  59. package/dist/client/runs/format-wait.js +2 -0
  60. package/dist/client/runs/format-wait.js.map +1 -1
  61. package/dist/package.json +1 -1
  62. package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
  63. package/dist/server/core/conversations/conversation.service.js +43 -3
  64. package/dist/server/core/conversations/conversation.service.js.map +1 -1
  65. package/dist/server/core/conversations/links.d.ts +3 -0
  66. package/dist/server/core/conversations/links.d.ts.map +1 -1
  67. package/dist/server/core/conversations/links.js +33 -0
  68. package/dist/server/core/conversations/links.js.map +1 -1
  69. package/dist/server/core/conversations/ports.d.ts +1 -1
  70. package/dist/server/core/conversations/ports.d.ts.map +1 -1
  71. package/dist/server/core/runs/claim.d.ts.map +1 -1
  72. package/dist/server/core/runs/claim.js +24 -7
  73. package/dist/server/core/runs/claim.js.map +1 -1
  74. package/dist/server/core/runs/run.service.d.ts +1 -1
  75. package/dist/server/core/runs/run.service.d.ts.map +1 -1
  76. package/dist/server/core/runs/run.service.js +7 -1
  77. package/dist/server/core/runs/run.service.js.map +1 -1
  78. package/dist/server/core/runs/workload.d.ts.map +1 -1
  79. package/dist/server/core/runs/workload.js +19 -2
  80. package/dist/server/core/runs/workload.js.map +1 -1
  81. package/dist/server/kernel/events.d.ts +2 -0
  82. package/dist/server/kernel/events.d.ts.map +1 -1
  83. package/dist/server/kernel/events.js.map +1 -1
  84. package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
  85. package/dist/server/online/agent/runner/backend.js +17 -8
  86. package/dist/server/online/agent/runner/backend.js.map +1 -1
  87. package/dist/server/providers/agents.d.ts.map +1 -1
  88. package/dist/server/providers/agents.js +3 -0
  89. package/dist/server/providers/agents.js.map +1 -1
  90. package/dist/server/routes/schemas.d.ts +1 -1
  91. package/dist/server/routes/schemas.d.ts.map +1 -1
  92. package/dist/server/routes/schemas.js +6 -2
  93. package/dist/server/routes/schemas.js.map +1 -1
  94. package/dist/shared/conversations.d.ts +6 -0
  95. package/dist/shared/conversations.d.ts.map +1 -1
  96. package/dist/shared/conversations.js.map +1 -1
  97. package/dist/shared/runs.d.ts +7 -3
  98. package/dist/shared/runs.d.ts.map +1 -1
  99. package/dist/shared/runs.js +2 -0
  100. package/dist/shared/runs.js.map +1 -1
  101. package/dist/shared/ui-message.d.ts +2 -2
  102. package/dist/shared/ui-message.d.ts.map +1 -1
  103. package/dist/shared/ui-message.js +1 -1
  104. package/dist/shared/ui-message.js.map +1 -1
  105. package/package.json +11 -11
  106. package/skills/nocobase-app-plugin-agents/references/verification-and-troubleshooting.md +2 -1
@@ -1,14 +1,17 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { ApiClientError } from '@nocobase/app-client';
3
- import { CancelledError } from '@tanstack/react-query';
3
+ import { CancelledError, hashKey } from '@tanstack/react-query';
4
4
  import { isToolUIPart } from 'ai';
5
5
  import { Chat, useChat as useSDKChat } from '@ai-sdk/react';
6
6
  import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from 'react';
7
- import { useApiClient } from '@nocobase/app-client';
7
+ import { useApiClient, useRealtimeReadiness } from '@nocobase/app-client';
8
+ import { CONVERSATIONS_TOPIC, } from '../../shared/conversations.js';
8
9
  import { ChatApi } from './api.js';
10
+ import { CHAT_INVALIDATION_MS, coalescedInvalidation, } from './coalesced-invalidation.js';
9
11
  import { chatIsRunning } from './feedback.js';
10
12
  import { ChatSubmissions } from './submissions.js';
11
13
  import { agentsQueryClient } from '../query.js';
14
+ import { agentsKeys } from '../api/keys.js';
12
15
  import { chatKeys } from './keys.js';
13
16
  import { EMPTY_MESSAGES, messagesReducer, mergeUIMessages, } from './message-model.js';
14
17
  function deferred() {
@@ -22,14 +25,82 @@ function deferred() {
22
25
  /** Application coordination around one SDK Chat per conversation, shared by every surface. */
23
26
  export class ChatController {
24
27
  api;
28
+ invalidations;
29
+ invalidateQuery(key, exact = false) {
30
+ (this.invalidations ??= coalescedInvalidation(agentsQueryClient())).add(key, exact);
31
+ }
32
+ invalidateLists() {
33
+ for (const query of agentsQueryClient()
34
+ .getQueryCache()
35
+ .findAll({ queryKey: chatKeys.lists })) {
36
+ if (query.queryKey[3]?.archived !==
37
+ true)
38
+ this.invalidateQuery(query.queryKey, true);
39
+ }
40
+ }
41
+ /** Reuse a detail snapshot when replacing its row cannot change membership, order or a page cursor. */
42
+ syncLists(detail) {
43
+ const client = agentsQueryClient();
44
+ for (const query of client
45
+ .getQueryCache()
46
+ .findAll({ queryKey: chatKeys.lists })) {
47
+ const filter = query.queryKey[3];
48
+ if (filter?.archived === true)
49
+ continue;
50
+ const data = query.state.data;
51
+ const pageIndex = data?.pages?.findIndex((page) => page.items.some((item) => item.id === detail.id)) ?? -1;
52
+ const page = data?.pages?.[pageIndex];
53
+ const index = page?.items.findIndex((item) => item.id === detail.id) ?? -1;
54
+ const row = page?.items[index];
55
+ if (row &&
56
+ ((row.lastSeq ?? 0) > (detail.lastSeq ?? 0) ||
57
+ row.updatedAt > detail.updatedAt))
58
+ continue;
59
+ const sameOrder = row &&
60
+ (row.lastMessageAt === detail.lastMessageAt ||
61
+ (pageIndex === 0 &&
62
+ index === 0 &&
63
+ detail.lastMessageAt >= row.lastMessageAt &&
64
+ (page.items.length > 1 || !page.nextCursor)));
65
+ if (!row ||
66
+ !sameOrder ||
67
+ query.state.fetchStatus !== 'idle' ||
68
+ row.archivedAt !== detail.archivedAt ||
69
+ row.showInChatList !== detail.showInChatList ||
70
+ filter?.q ||
71
+ filter?.agentId ||
72
+ filter?.mode ||
73
+ filter?.source ||
74
+ typeof filter === 'string') {
75
+ this.invalidateQuery(query.queryKey, true);
76
+ continue;
77
+ }
78
+ client.setQueryData(query.queryKey, {
79
+ ...data,
80
+ pages: data.pages.map((item, position) => position === pageIndex
81
+ ? {
82
+ ...item,
83
+ items: item.items.map((summary) => summary.id === detail.id ? { ...detail } : summary),
84
+ }
85
+ : item),
86
+ });
87
+ }
88
+ }
25
89
  historyEpochs = new Map();
90
+ messageEpochs = new Map();
26
91
  replacements = new Map();
27
92
  resendRequests = new Map();
93
+ streamFollowers = new Map();
94
+ detailReads = new Map();
95
+ refreshes = new Map();
28
96
  reconciles = new Map();
29
97
  submissions;
30
98
  chats = new Map();
31
99
  histories = new Map();
32
100
  loads = new Map();
101
+ messageReads = new Map();
102
+ catchups = new Map();
103
+ reconnects = new Map();
33
104
  refreshAgain = new Set();
34
105
  recoveries = new Map();
35
106
  admissions = new Map();
@@ -46,6 +117,7 @@ export class ChatController {
46
117
  generation = 0;
47
118
  detailVersions = new Map();
48
119
  receiptVersions = new Map();
120
+ pendingReceipts = new Map();
49
121
  commandVersions = new Map();
50
122
  constructor(api) {
51
123
  this.api = api;
@@ -80,7 +152,7 @@ export class ChatController {
80
152
  recovery.error = undefined;
81
153
  // Reads started before reconnection must not replace its replay or revive old errors.
82
154
  this.nextDetailVersion(id);
83
- this.invalidateHistoryReads(id);
155
+ this.invalidateReplayReads(id);
84
156
  this.publish();
85
157
  };
86
158
  }),
@@ -194,22 +266,33 @@ export class ChatController {
194
266
  this.publish();
195
267
  }
196
268
  historyLifetime(id) {
197
- const active = this.lifetime(), epoch = this.historyEpochs.get(id) ?? 0, command = this.commandVersions.get(id);
269
+ return this.readLifetime(id, this.historyEpochs);
270
+ }
271
+ messageLifetime(id) {
272
+ return this.readLifetime(id, this.messageEpochs);
273
+ }
274
+ readLifetime(id, epochs) {
275
+ const active = this.lifetime(), epoch = epochs.get(id) ?? 0, command = this.commandVersions.get(id);
198
276
  return () => active() &&
199
- (this.historyEpochs.get(id) ?? 0) === epoch &&
277
+ (epochs.get(id) ?? 0) === epoch &&
200
278
  this.commandVersions.get(id) === command;
201
279
  }
202
- invalidateHistoryReads(id, discardDeferred = false) {
280
+ /** A resumed stream replaces replay snapshots, not already requested durable message revisions. */
281
+ invalidateReplayReads(id) {
203
282
  this.historyEpochs.set(id, (this.historyEpochs.get(id) ?? 0) + 1);
204
283
  this.loads.delete(id);
205
284
  this.refreshAgain.delete(id);
285
+ this.historyMeta(id).loadingOlder = false;
286
+ }
287
+ invalidateHistoryReads(id, discardDeferred = false) {
288
+ this.invalidateReplayReads(id);
289
+ this.messageEpochs.set(id, (this.messageEpochs.get(id) ?? 0) + 1);
206
290
  this.revisionQueue.delete(id);
207
291
  if (discardDeferred)
208
292
  this.deferredReads.delete(id);
209
293
  for (const key of this.messageVersions.keys())
210
294
  if (key.startsWith(`${id}:`))
211
295
  this.messageVersions.delete(key);
212
- this.historyMeta(id).loadingOlder = false;
213
296
  }
214
297
  historyReplaced(event) {
215
298
  if (this.disposed || !this.replaceHistory(event))
@@ -403,42 +486,36 @@ export class ChatController {
403
486
  return pending;
404
487
  if (this.resendRequests.has(id))
405
488
  return;
406
- this.invalidateHistoryReads(id);
489
+ this.invalidateReplayReads(id);
407
490
  const active = this.historyLifetime(id);
408
491
  const initial = this.chat(id).messages.filter((m) => m.metadata?.seq !== undefined);
409
492
  const task = (async () => {
410
493
  const found = [];
411
- for (let offset = 0; offset < initial.length; offset += 100) {
412
- const page = await this.api.messages(id, {
413
- ids: initial
414
- .slice(offset, offset + 100)
415
- .map((m) => m.id)
416
- .join(','),
417
- });
494
+ // Reconnect reads the current window once, covering insertions, revisions and removed replies together.
495
+ const first = await this.api.messages(id, {});
496
+ if (!active())
497
+ return;
498
+ found.push(...first.items);
499
+ let window = first;
500
+ const oldest = Math.min(...initial.map((m) => m.metadata.seq));
501
+ const seen = new Set();
502
+ while (window.nextCursor &&
503
+ window.items.length &&
504
+ window.items[0].metadata.seq > oldest) {
505
+ if (seen.has(window.nextCursor))
506
+ throw new Error('History pagination did not advance.');
507
+ seen.add(window.nextCursor);
508
+ window = await this.api.messages(id, { pageToken: window.nextCursor });
418
509
  if (!active())
419
510
  return;
420
- found.push(...page.items);
511
+ found.push(...window.items);
421
512
  }
422
513
  const present = new Set(found.map((m) => m.id));
423
514
  const missing = new Set(initial.filter((m) => !present.has(m.id)).map((m) => m.id));
424
- let window;
425
- if (missing.size) {
426
- const oldest = Math.min(...initial.map((m) => m.metadata.seq));
427
- const seen = new Set();
428
- do {
429
- window = await this.api.messages(id, window?.nextCursor ? { pageToken: window.nextCursor } : {});
430
- if (!active())
431
- return;
432
- found.push(...window.items);
433
- if (!window.nextCursor ||
434
- !window.items.length ||
435
- window.items[0].metadata.seq <= oldest)
436
- break;
437
- if (seen.has(window.nextCursor))
438
- throw new Error('History pagination did not advance.');
439
- seen.add(window.nextCursor);
440
- } while (window.nextCursor);
441
- }
515
+ this.dispatch(id, { type: 'page', page: first });
516
+ this.submissions.observe(id, found);
517
+ this.historyMeta(id).loadedThrough = first.lastSeq;
518
+ this.historyMeta(id).error = undefined;
442
519
  if (!active())
443
520
  return;
444
521
  const changed = found.find((m) => m.role === 'user' &&
@@ -469,6 +546,13 @@ export class ChatController {
469
546
  ? chat.messages.at(-1)?.id
470
547
  : undefined;
471
548
  chat.messages = mergeUIMessages(chat.messages.filter((m) => !missing.has(m.id) || m.id === activeId), found, activeId);
549
+ const deferred = this.deferredReads.get(id);
550
+ for (const message of found)
551
+ if ((message.metadata?.revision ?? 0) >=
552
+ (this.messageVersions.get(`${id}:${message.id}`) ?? 0))
553
+ deferred?.delete(message.id);
554
+ if (deferred?.size === 0)
555
+ this.deferredReads.delete(id);
472
556
  if (window)
473
557
  this.historyMeta(id).page = {
474
558
  ...this.historyMeta(id).page,
@@ -562,13 +646,41 @@ export class ChatController {
562
646
  if (this.disposed)
563
647
  return [];
564
648
  const found = [];
565
- const active = this.historyLifetime(id);
649
+ const active = this.messageLifetime(id);
650
+ const replayActive = this.historyLifetime(id);
566
651
  for (let offset = 0; offset < ids.length; offset += 100) {
567
- const page = await this.api.messages(id, {
568
- ids: ids.slice(offset, offset + 100).join(','),
569
- });
652
+ const selection = ids.slice(offset, offset + 100);
653
+ const key = JSON.stringify([id, [...selection].sort()]);
654
+ let pendingRead = this.messageReads.get(key);
655
+ const shared = Boolean(pendingRead);
656
+ if (!pendingRead) {
657
+ pendingRead = this.api.messages(id, { ids: selection.join(',') });
658
+ this.messageReads.set(key, pendingRead);
659
+ const clear = () => {
660
+ if (this.messageReads.get(key) === pendingRead)
661
+ this.messageReads.delete(key);
662
+ };
663
+ void pendingRead.then(clear, clear);
664
+ }
665
+ const response = await pendingRead;
570
666
  if (!active())
571
667
  return [];
668
+ // Durable user/system revisions survive reconnection; old assistant snapshots must not overwrite replay.
669
+ const page = replayActive()
670
+ ? response
671
+ : {
672
+ ...response,
673
+ items: response.items.filter((message) => message.role !== 'assistant'),
674
+ };
675
+ if (shared &&
676
+ active() &&
677
+ page.items.some((message) => (message.metadata?.revision ?? 0) <
678
+ (this.messageVersions.get(`${id}:${message.id}`) ?? 0))) {
679
+ // A revision announced during the older read needs a causal trailing read, never a cancellation.
680
+ const saved = await this.reread(id, selection);
681
+ found.push(...saved);
682
+ continue;
683
+ }
572
684
  found.push(...page.items);
573
685
  const pending = this.deferredReads.get(id);
574
686
  for (const message of page.items) {
@@ -583,6 +695,42 @@ export class ChatController {
583
695
  }
584
696
  return found;
585
697
  }
698
+ /** A cursor announcement shares the current read; only an actual gap needs a trailing page. */
699
+ messagesChanged(id, lastSeq) {
700
+ if (this.disposed || !this.watches.has(id))
701
+ return Promise.resolve();
702
+ const pending = this.catchups.get(id);
703
+ if (pending) {
704
+ pending.lastSeq = Math.max(pending.lastSeq, lastSeq);
705
+ return pending.task;
706
+ }
707
+ const active = this.historyLifetime(id);
708
+ const owner = { lastSeq, task: Promise.resolve() };
709
+ const run = async () => {
710
+ const reading = this.loads.get(id);
711
+ if (reading)
712
+ await reading;
713
+ while (active() &&
714
+ (!this.history(id).state.loaded ||
715
+ this.history(id).loadedThrough < owner.lastSeq)) {
716
+ const before = this.history(id).loadedThrough;
717
+ await this.loadHistory(id);
718
+ if (this.history(id).error)
719
+ throw this.history(id).error;
720
+ if (this.history(id).loadedThrough <= before)
721
+ break;
722
+ }
723
+ };
724
+ owner.task = run().finally(() => {
725
+ if (this.catchups.get(id) === owner)
726
+ this.catchups.delete(id);
727
+ });
728
+ this.catchups.set(id, owner);
729
+ return owner.task;
730
+ }
731
+ isWatching(id) {
732
+ return this.watches.has(id);
733
+ }
586
734
  watch(id) {
587
735
  let watch = this.watches.get(id);
588
736
  if (!watch) {
@@ -592,11 +740,15 @@ export class ChatController {
592
740
  return;
593
741
  const detail = agentsQueryClient().getQueryData(chatKeys.conversation(id));
594
742
  const current = this.watches.get(id);
595
- const interval = detail?.execution?.status === 'running' ? 5000 : 30000;
743
+ const status = this.chats.get(id)?.status;
744
+ const busy = chatIsRunning(detail ?? null) ||
745
+ status === 'submitted' ||
746
+ status === 'streaming';
747
+ const interval = busy ? 5000 : 30000;
596
748
  if (Date.now() - current.last < interval)
597
749
  return;
598
750
  current.last = Date.now();
599
- void this.refresh(id).catch(() => { });
751
+ void this.refresh(id, true, !busy, false).catch(() => { });
600
752
  }, 5000);
601
753
  watch = { refs: 0, timer, last: Date.now() };
602
754
  this.watches.set(id, watch);
@@ -611,12 +763,24 @@ export class ChatController {
611
763
  };
612
764
  }
613
765
  reconnect() {
614
- for (const id of this.watches.keys())
615
- void this.refresh(id)
766
+ this.invalidateLists();
767
+ for (const id of this.watches.keys()) {
768
+ if (this.reconnects.has(id))
769
+ continue;
770
+ const task = this.refresh(id)
616
771
  .then(() => this.resume(id))
617
- .catch(() => { });
772
+ .catch(() => { })
773
+ .finally(() => {
774
+ if (this.reconnects.get(id) === task)
775
+ this.reconnects.delete(id);
776
+ });
777
+ this.reconnects.set(id, task);
778
+ }
618
779
  }
619
780
  messageUpdated(id, messageId, revision) {
781
+ const saved = this.chat(id).messages.find((message) => message.id === messageId);
782
+ if ((saved?.metadata?.revision ?? -1) >= revision)
783
+ return;
620
784
  const key = `${id}:${messageId}`;
621
785
  if (((this.messageVersions.get(key) ?? -1) >= revision &&
622
786
  !this.deferredReads.get(id)?.has(messageId)) ||
@@ -630,13 +794,21 @@ export class ChatController {
630
794
  }
631
795
  const queue = { pending: new Set([messageId]), task: Promise.resolve() };
632
796
  this.revisionQueue.set(id, queue);
633
- const active = this.historyLifetime(id);
797
+ const active = this.messageLifetime(id);
634
798
  queue.task = (async () => {
635
799
  const attempted = new Set();
636
800
  try {
801
+ // Let a concurrent cursor read or SDK finish supply the same durable revision first.
802
+ await new Promise((resolve) => setTimeout(resolve, CHAT_INVALIDATION_MS));
803
+ const reading = this.loads.get(id);
804
+ if (reading)
805
+ await reading;
637
806
  while (active() && queue.pending.size) {
638
- const ids = [...queue.pending];
807
+ const ids = [...queue.pending].filter((messageId) => (this.chat(id).messages.find((message) => message.id === messageId)?.metadata?.revision ?? -1) <
808
+ (this.messageVersions.get(`${id}:${messageId}`) ?? 0));
639
809
  queue.pending.clear();
810
+ if (!ids.length)
811
+ continue;
640
812
  ids.forEach((id) => attempted.add(id));
641
813
  await this.reread(id, ids);
642
814
  }
@@ -774,6 +946,19 @@ export class ChatController {
774
946
  ?.id ??
775
947
  '');
776
948
  }
949
+ followStream(id) {
950
+ const count = (this.streamFollowers.get(id) ?? 0) + 1;
951
+ this.streamFollowers.set(id, count);
952
+ if (count === 1)
953
+ this.receive(id);
954
+ return () => {
955
+ const remaining = (this.streamFollowers.get(id) ?? 1) - 1;
956
+ if (remaining === 0)
957
+ this.streamFollowers.delete(id);
958
+ else
959
+ this.streamFollowers.set(id, remaining);
960
+ };
961
+ }
777
962
  receive(id, known) {
778
963
  if (this.disposed)
779
964
  return;
@@ -793,7 +978,10 @@ export class ChatController {
793
978
  return;
794
979
  if (!chatIsRunning(detail) || detail.execution?.waitingApproval) {
795
980
  const chat = this.chats.get(id);
796
- if (chat?.status !== 'submitted' && chat?.status !== 'streaming')
981
+ if (chat?.status !== 'submitted' &&
982
+ chat?.status !== 'streaming' &&
983
+ (!this.history(id).state.loaded ||
984
+ this.history(id).loadedThrough < (detail.lastSeq ?? 0)))
797
985
  await this.loadHistory(id);
798
986
  return;
799
987
  }
@@ -870,7 +1058,13 @@ export class ChatController {
870
1058
  recovery.phase = 'interrupted';
871
1059
  recovery.error = chat.error;
872
1060
  }
873
- await this.refresh(id);
1061
+ if (agentsQueryClient()
1062
+ .getQueryCache()
1063
+ .find({ queryKey: chatKeys.conversation(id), exact: true })
1064
+ ?.isActive())
1065
+ this.scheduleRefresh(id);
1066
+ else
1067
+ await this.refresh(id);
874
1068
  }
875
1069
  }
876
1070
  catch (error) {
@@ -947,9 +1141,63 @@ export class ChatController {
947
1141
  if (active())
948
1142
  await this.refresh(id);
949
1143
  }
950
- async refresh(id) {
1144
+ /** One detail request shared by the query observer and message reconciliation. */
1145
+ readDetail(id, fresh = false) {
1146
+ const pending = this.detailReads.get(id);
1147
+ if (pending)
1148
+ return fresh
1149
+ ? pending.catch(() => undefined).then(() => this.readDetail(id))
1150
+ : pending;
1151
+ const task = this.api.conversation(id);
1152
+ this.detailReads.set(id, task);
1153
+ const clear = () => {
1154
+ if (this.detailReads.get(id) === task)
1155
+ this.detailReads.delete(id);
1156
+ };
1157
+ void task.then(clear, clear);
1158
+ return task;
1159
+ }
1160
+ refresh(id, fresh = true, idleProbe = false, reconcileHistory = true) {
1161
+ if (this.disposed)
1162
+ return Promise.resolve();
1163
+ const pending = this.refreshes.get(id);
1164
+ if (pending) {
1165
+ if (fresh) {
1166
+ pending.trailing = true;
1167
+ this.nextDetailVersion(id);
1168
+ }
1169
+ return pending.task;
1170
+ }
1171
+ const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion;
1172
+ const owner = { trailing: false, task: Promise.resolve() };
1173
+ const run = async () => {
1174
+ do {
1175
+ owner.trailing = false;
1176
+ await this.refreshOnce(id, fresh, idleProbe, undefined, reconcileHistory);
1177
+ fresh = true;
1178
+ idleProbe = false;
1179
+ } while (owner.trailing && active());
1180
+ };
1181
+ owner.task = run().finally(() => {
1182
+ if (this.refreshes.get(id) === owner)
1183
+ this.refreshes.delete(id);
1184
+ });
1185
+ this.refreshes.set(id, owner);
1186
+ return owner.task;
1187
+ }
1188
+ async refreshOnce(id, fresh, idleProbe = false, snapshot, reconcileHistory = true) {
951
1189
  if (this.disposed)
952
1190
  return;
1191
+ const waiting = this.lifetime();
1192
+ if ((!fresh || !reconcileHistory) && !snapshot) {
1193
+ const query = agentsQueryClient()
1194
+ .getQueryCache()
1195
+ .find({ queryKey: chatKeys.conversation(id), exact: true });
1196
+ if (query?.state.fetchStatus === 'fetching' && query.promise)
1197
+ snapshot = (await query.promise);
1198
+ if (!waiting())
1199
+ return;
1200
+ }
953
1201
  const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion, ordered = this.detailRequest(id);
954
1202
  const submittedOwner = this.chat(id);
955
1203
  const submittedInput = submittedOwner.status === 'submitted' && !this.admissions.has(id)
@@ -957,16 +1205,62 @@ export class ChatController {
957
1205
  .reverse()
958
1206
  .find((message) => message.role === 'user' && message.metadata?.seq !== undefined)
959
1207
  : undefined;
960
- const detail = await this.api.conversation(id);
1208
+ const query = agentsQueryClient()
1209
+ .getQueryCache()
1210
+ .find({ queryKey: chatKeys.conversation(id), exact: true });
1211
+ const cached = !fresh && query?.isActive() && query.state.fetchStatus === 'idle'
1212
+ ? query.state.data
1213
+ : undefined;
1214
+ const previous = query?.state.data;
1215
+ const detail = snapshot ?? cached ?? (await this.readDetail(id, fresh));
961
1216
  if (!active())
962
1217
  return;
963
- if (ordered())
1218
+ if (idleProbe &&
1219
+ previous &&
1220
+ !chatIsRunning(detail) &&
1221
+ detail.lastSeq !== undefined &&
1222
+ this.history(id).state.loaded &&
1223
+ !this.history(id).error &&
1224
+ this.history(id).loadedThrough >= detail.lastSeq &&
1225
+ hashKey([previous]) === hashKey([detail])) {
1226
+ // The durable cursor is current. Still reread revisions of the latest assistant response: a same-sequence
1227
+ // update may have lost its announcement, even though the conversation snapshot did not change.
1228
+ const latest = [...this.chat(id).messages]
1229
+ .reverse()
1230
+ .find((message) => message.role === 'assistant');
1231
+ const ids = [
1232
+ ...new Set([
1233
+ ...(this.deferredReads.get(id) ?? []),
1234
+ ...(latest ? [latest.id] : []),
1235
+ ]),
1236
+ ];
1237
+ if (ids.length)
1238
+ await this.reread(id, ids);
1239
+ if (active() && ordered()) {
1240
+ this.remember(detail, false);
1241
+ this.publish();
1242
+ }
1243
+ return;
1244
+ }
1245
+ if (!active())
1246
+ return;
1247
+ const reconciled = fresh && !snapshot && reconcileHistory && ordered();
1248
+ if (reconciled)
964
1249
  await this.reconcile(id, detail);
965
1250
  if (!active())
966
1251
  return;
967
1252
  // A newer detail query must not discard this refresh's message catch-up.
968
1253
  // loadHistory coalesces overlapping reads independently of detail ordering.
969
- await this.loadHistory(id, false, true);
1254
+ const beforeThrough = this.history(id).loadedThrough;
1255
+ if ((!snapshot && !reconciled && fresh && detail.lastSeq === undefined) ||
1256
+ (reconciled && !ordered()) ||
1257
+ !this.history(id).state.loaded ||
1258
+ this.history(id).loadedThrough < (detail.lastSeq ?? 0)) {
1259
+ if (!reconcileHistory && detail.lastSeq !== undefined)
1260
+ await this.messagesChanged(id, detail.lastSeq);
1261
+ else
1262
+ await this.loadHistory(id, false, fresh);
1263
+ }
970
1264
  if (!active())
971
1265
  return;
972
1266
  const history = this.history(id);
@@ -981,14 +1275,23 @@ export class ChatController {
981
1275
  const revisable = [
982
1276
  ...new Set([
983
1277
  ...(this.deferredReads.get(id) ?? []),
984
- ...(detail.execution?.messageId
1278
+ ...(detail.execution?.messageId &&
1279
+ ((!snapshot &&
1280
+ !reconciled &&
1281
+ this.history(id).loadedThrough === beforeThrough) ||
1282
+ (this.chat(id).messages.find((m) => m.id === detail.execution?.messageId)?.metadata?.revision ?? -1) < (detail.execution.revision ?? 0))
985
1283
  ? [detail.execution.messageId]
986
- : latest
1284
+ : !snapshot &&
1285
+ !reconciled &&
1286
+ this.history(id).loadedThrough === beforeThrough &&
1287
+ latest
987
1288
  ? [latest.id]
988
1289
  : []),
989
1290
  ]),
990
1291
  ];
991
- const saved = revisable.length ? await this.reread(id, revisable) : [];
1292
+ const saved = revisable.length
1293
+ ? await this.reread(id, revisable)
1294
+ : [...this.chat(id).messages];
992
1295
  if (!active() || !ordered())
993
1296
  return;
994
1297
  const owner = this.chat(id), receiving = owner.messages.at(-1);
@@ -1023,7 +1326,8 @@ export class ChatController {
1023
1326
  }
1024
1327
  if (!active() || !ordered())
1025
1328
  return;
1026
- this.remember(detail, false);
1329
+ if (!snapshot)
1330
+ this.remember(detail, false);
1027
1331
  const chat = this.chat(id);
1028
1332
  if (detail.execution?.status === 'idle') {
1029
1333
  this.recoveries.delete(id);
@@ -1033,6 +1337,13 @@ export class ChatController {
1033
1337
  this.publish();
1034
1338
  }
1035
1339
  scheduleRefresh(id) {
1340
+ if (agentsQueryClient()
1341
+ .getQueryCache()
1342
+ .find({ queryKey: chatKeys.conversation(id), exact: true })
1343
+ ?.isActive()) {
1344
+ this.invalidateQuery(chatKeys.conversation(id), true);
1345
+ return;
1346
+ }
1036
1347
  if (this.completionReads.has(id))
1037
1348
  return;
1038
1349
  this.completionReads.add(id);
@@ -1042,14 +1353,16 @@ export class ChatController {
1042
1353
  void this.refresh(id).catch(() => { });
1043
1354
  });
1044
1355
  }
1045
- observeDetail(detail, refresh = true) {
1356
+ observeDetail(detail, refresh = true, synchronizeLists = true) {
1357
+ if (synchronizeLists)
1358
+ this.syncLists(detail);
1046
1359
  if (detail.execution?.status === 'running')
1047
1360
  this.receive(detail.id, detail);
1048
1361
  if (!chatIsRunning(detail)) {
1049
1362
  if (refresh &&
1050
1363
  (this.chats.get(detail.id)?.status === 'streaming' ||
1051
1364
  this.chats.get(detail.id)?.status === 'submitted'))
1052
- this.scheduleRefresh(detail.id);
1365
+ void this.refreshOnce(detail.id, false, false, detail).catch(() => { });
1053
1366
  this.stopRequests.delete(detail.id);
1054
1367
  this.recoveries.delete(detail.id);
1055
1368
  this.publish();
@@ -1088,29 +1401,103 @@ export class ChatController {
1088
1401
  }
1089
1402
  remember(detail, refresh = true) {
1090
1403
  this.nextDetailVersion(detail.id);
1091
- this.observeDetail(detail, refresh);
1092
- agentsQueryClient().setQueryData(chatKeys.conversation(detail.id), detail);
1093
- void agentsQueryClient().invalidateQueries({ queryKey: chatKeys.lists });
1404
+ // A reconnect already owns a fresh list read; its later history response must not schedule another one.
1405
+ this.observeDetail(detail, refresh, !this.reconnects.has(detail.id));
1406
+ const client = agentsQueryClient();
1407
+ client.setQueryData(chatKeys.conversation(detail.id), detail);
1408
+ }
1409
+ /** The HTTP receipt supplies this tab's snapshot; retain a newer read position for a trailing read. */
1410
+ observeReceipt(id, seenSeq) {
1411
+ const update = (item) => {
1412
+ if (item.id !== id || (item.seenSeq ?? -1) >= seenSeq)
1413
+ return item;
1414
+ return {
1415
+ ...item,
1416
+ seenSeq,
1417
+ read: seenSeq >= (item.lastSeq ?? 0) || item.read,
1418
+ needsYou: item.needsYou &&
1419
+ item.needsYou.reason !== 'confirm' &&
1420
+ item.needsYou.seq <= seenSeq
1421
+ ? null
1422
+ : item.needsYou,
1423
+ };
1424
+ };
1425
+ agentsQueryClient().setQueriesData({ queryKey: chatKeys.lists }, (old) => !old
1426
+ ? old
1427
+ : Array.isArray(old)
1428
+ ? old.map(update)
1429
+ : {
1430
+ ...old,
1431
+ pages: old.pages.map((page) => ({
1432
+ ...page,
1433
+ items: page.items.map(update),
1434
+ })),
1435
+ });
1436
+ }
1437
+ deferReadReceipt(id, seenSeq, refresh) {
1438
+ const pending = this.pendingReceipts.get(id);
1439
+ if (!pending)
1440
+ return false;
1441
+ pending.seenSeq = Math.max(pending.seenSeq, seenSeq);
1442
+ pending.refresh = refresh;
1443
+ return true;
1094
1444
  }
1095
1445
  async markRead(id) {
1446
+ const client = agentsQueryClient();
1447
+ const key = chatKeys.conversation(id);
1448
+ const before = client.getQueryData(key);
1096
1449
  const active = this.lifetime(), commandVersion = this.commandVersions.get(id), receiptVersion = (this.receiptVersions.get(id) ?? 0) + 1;
1097
1450
  // Detail reads must not suppress the read transition needed by a viewed conversation.
1098
1451
  this.receiptVersions.set(id, receiptVersion);
1452
+ const pending = { seenSeq: -1 };
1453
+ this.pendingReceipts.set(id, pending);
1099
1454
  this.nextDetailVersion(id);
1100
- const detail = await this.api.markRead(id);
1101
- if (active() &&
1102
- this.commandVersions.get(id) === commandVersion &&
1103
- this.receiptVersions.get(id) === receiptVersion) {
1104
- // Read receipts do not change execution or invalidate pending history.
1105
- this.nextDetailVersion(id);
1106
- agentsQueryClient().setQueryData(chatKeys.conversation(id), detail);
1107
- void agentsQueryClient().invalidateQueries({ queryKey: chatKeys.lists });
1455
+ try {
1456
+ const detail = await this.api.markRead(id);
1457
+ if (active() &&
1458
+ this.commandVersions.get(id) === commandVersion &&
1459
+ this.receiptVersions.get(id) === receiptVersion) {
1460
+ this.observeReceipt(id, detail.seenSeq ?? 0);
1461
+ // Read receipts do not change execution or invalidate pending history.
1462
+ this.nextDetailVersion(id);
1463
+ client.setQueryData(key, (current) => {
1464
+ if (!current ||
1465
+ current === before ||
1466
+ (current.lastSeq ?? 0) < (detail.lastSeq ?? 0) ||
1467
+ (current.lastSeq === detail.lastSeq &&
1468
+ current.updatedAt < detail.updatedAt))
1469
+ return detail;
1470
+ // A later snapshot may already contain the next reply or a metadata change.
1471
+ const seenSeq = Math.max(current.seenSeq ?? 0, detail.seenSeq ?? 0);
1472
+ return {
1473
+ ...current,
1474
+ seenSeq,
1475
+ read: seenSeq >= (current.lastSeq ?? 0) || current.read,
1476
+ needsYou: current.needsYou &&
1477
+ current.needsYou.reason !== 'confirm' &&
1478
+ current.needsYou.seq <= seenSeq
1479
+ ? null
1480
+ : current.needsYou,
1481
+ };
1482
+ });
1483
+ }
1484
+ return detail;
1485
+ }
1486
+ finally {
1487
+ if (this.pendingReceipts.get(id) === pending) {
1488
+ this.pendingReceipts.delete(id);
1489
+ const current = agentsQueryClient().getQueryData(chatKeys.conversation(id));
1490
+ if (active() && pending.seenSeq > (current?.seenSeq ?? -1))
1491
+ pending.refresh?.();
1492
+ }
1108
1493
  }
1109
- return detail;
1110
1494
  }
1111
1495
  async command(id, request, detail) {
1112
1496
  const active = this.lifetime(), version = (this.commandVersions.get(id) ?? 0) + 1;
1113
1497
  this.commandVersions.set(id, version);
1498
+ // A command replaces the snapshot generation; its reads must not wait on obsolete HTTP.
1499
+ this.detailReads.delete(id);
1500
+ this.refreshes.delete(id);
1114
1501
  this.nextDetailVersion(id);
1115
1502
  this.invalidateHistoryReads(id);
1116
1503
  const recovery = this.recoveries.get(id);
@@ -1143,6 +1530,8 @@ export class ChatController {
1143
1530
  this.disposed = false;
1144
1531
  }
1145
1532
  invalidate() {
1533
+ this.invalidations?.stop();
1534
+ this.invalidations = undefined;
1146
1535
  if (this.disposed)
1147
1536
  return;
1148
1537
  this.disposed = true;
@@ -1155,8 +1544,12 @@ export class ChatController {
1155
1544
  request.gate.reject(new CancelledError({ silent: true }));
1156
1545
  this.resendRequests.clear();
1157
1546
  this.historyEpochs.clear();
1547
+ this.messageEpochs.clear();
1158
1548
  this.replacements.clear();
1159
1549
  this.reconciles.clear();
1550
+ this.streamFollowers.clear();
1551
+ this.detailReads.clear();
1552
+ this.refreshes.clear();
1160
1553
  for (const watch of this.watches.values())
1161
1554
  clearInterval(watch.timer);
1162
1555
  this.watches.clear();
@@ -1166,14 +1559,19 @@ export class ChatController {
1166
1559
  this.completionReads.clear();
1167
1560
  this.admissions.clear();
1168
1561
  this.loads.clear();
1562
+ this.messageReads.clear();
1563
+ this.catchups.clear();
1564
+ this.reconnects.clear();
1169
1565
  this.refreshAgain.clear();
1170
1566
  this.commandVersions.clear();
1171
1567
  this.receiptVersions.clear();
1568
+ this.pendingReceipts.clear();
1172
1569
  this.stopRequests.clear();
1173
1570
  this.approvalRequests.clear();
1174
1571
  this.recoveries.clear();
1175
1572
  this.submissions.invalidate();
1176
- agentsQueryClient().removeQueries({ queryKey: chatKeys.all });
1573
+ // All plugin snapshots are viewer-scoped, including fresh agent options.
1574
+ agentsQueryClient().removeQueries({ queryKey: agentsKeys.all });
1177
1575
  }
1178
1576
  dispose() {
1179
1577
  this.invalidate();
@@ -1192,10 +1590,7 @@ export function ChatClientProvider({ children, }) {
1192
1590
  controller.activate();
1193
1591
  const lifetimes = lifetimesRef.current;
1194
1592
  lifetimes.set(controller, (lifetimes.get(controller) ?? 0) + 1);
1195
- const reconnect = () => controller.reconnect();
1196
- window.addEventListener('online', reconnect);
1197
1593
  return () => {
1198
- window.removeEventListener('online', reconnect);
1199
1594
  controller.invalidate();
1200
1595
  lifetimes.set(controller, (lifetimes.get(controller) ?? 1) - 1);
1201
1596
  queueMicrotask(() => {
@@ -1216,12 +1611,13 @@ export function useChatController() {
1216
1611
  }
1217
1612
  export function useChatStream(id) {
1218
1613
  const controller = useChatController();
1614
+ const ready = useRealtimeReadiness([CONVERSATIONS_TOPIC]);
1219
1615
  useSDKChat({ chat: controller.chat(id ?? 'draft') });
1220
1616
  useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot);
1221
1617
  useEffect(() => {
1222
- if (id)
1223
- controller.receive(id);
1224
- }, [controller, id]);
1618
+ if (ready && id)
1619
+ return controller.followStream(id);
1620
+ }, [controller, id, ready]);
1225
1621
  return id ? controller.state(id) : undefined;
1226
1622
  }
1227
1623
  export function useChatStreamState(id) {