@nocobase/app-plugin-agents 2.0.0-beta.0 → 2.0.0-beta.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +6 -0
  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 +25 -3
  8. package/dist/client/chat/controller.d.ts.map +1 -1
  9. package/dist/client/chat/controller.js +443 -68
  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/pages/agents/agent-dialog.js +4 -1
  26. package/dist/client/pages/agents/agent-dialog.js.map +1 -1
  27. package/dist/package.json +1 -1
  28. package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
  29. package/dist/server/core/conversations/conversation.service.js +14 -3
  30. package/dist/server/core/conversations/conversation.service.js.map +1 -1
  31. package/dist/server/core/conversations/links.d.ts +3 -0
  32. package/dist/server/core/conversations/links.d.ts.map +1 -1
  33. package/dist/server/core/conversations/links.js +33 -0
  34. package/dist/server/core/conversations/links.js.map +1 -1
  35. package/dist/server/core/runs/run.service.d.ts +1 -1
  36. package/dist/server/core/runs/run.service.d.ts.map +1 -1
  37. package/dist/server/core/runs/run.service.js +7 -1
  38. package/dist/server/core/runs/run.service.js.map +1 -1
  39. package/dist/server/kernel/events.d.ts +2 -0
  40. package/dist/server/kernel/events.d.ts.map +1 -1
  41. package/dist/server/kernel/events.js.map +1 -1
  42. package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
  43. package/dist/server/online/agent/runner/backend.js +17 -8
  44. package/dist/server/online/agent/runner/backend.js.map +1 -1
  45. package/dist/server/providers/agents.d.ts.map +1 -1
  46. package/dist/server/providers/agents.js +3 -0
  47. package/dist/server/providers/agents.js.map +1 -1
  48. package/dist/server/routes/schemas.d.ts +1 -1
  49. package/dist/server/routes/schemas.d.ts.map +1 -1
  50. package/dist/server/routes/schemas.js +5 -2
  51. package/dist/server/routes/schemas.js.map +1 -1
  52. package/dist/shared/conversations.d.ts +4 -0
  53. package/dist/shared/conversations.d.ts.map +1 -1
  54. package/dist/shared/conversations.js.map +1 -1
  55. package/dist/shared/ui-message.d.ts +2 -2
  56. package/dist/shared/ui-message.d.ts.map +1 -1
  57. package/dist/shared/ui-message.js +1 -1
  58. package/dist/shared/ui-message.js.map +1 -1
  59. package/package.json +10 -10
@@ -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,81 @@ 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();
26
90
  replacements = new Map();
27
91
  resendRequests = new Map();
92
+ streamFollowers = new Map();
93
+ detailReads = new Map();
94
+ refreshes = new Map();
28
95
  reconciles = new Map();
29
96
  submissions;
30
97
  chats = new Map();
31
98
  histories = new Map();
32
99
  loads = new Map();
100
+ messageReads = new Map();
101
+ catchups = new Map();
102
+ reconnects = new Map();
33
103
  refreshAgain = new Set();
34
104
  recoveries = new Map();
35
105
  admissions = new Map();
@@ -46,6 +116,7 @@ export class ChatController {
46
116
  generation = 0;
47
117
  detailVersions = new Map();
48
118
  receiptVersions = new Map();
119
+ pendingReceipts = new Map();
49
120
  commandVersions = new Map();
50
121
  constructor(api) {
51
122
  this.api = api;
@@ -408,37 +479,31 @@ export class ChatController {
408
479
  const initial = this.chat(id).messages.filter((m) => m.metadata?.seq !== undefined);
409
480
  const task = (async () => {
410
481
  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
- });
482
+ // Reconnect reads the current window once, covering insertions, revisions and removed replies together.
483
+ const first = await this.api.messages(id, {});
484
+ if (!active())
485
+ return;
486
+ found.push(...first.items);
487
+ let window = first;
488
+ const oldest = Math.min(...initial.map((m) => m.metadata.seq));
489
+ const seen = new Set();
490
+ while (window.nextCursor &&
491
+ window.items.length &&
492
+ window.items[0].metadata.seq > oldest) {
493
+ if (seen.has(window.nextCursor))
494
+ throw new Error('History pagination did not advance.');
495
+ seen.add(window.nextCursor);
496
+ window = await this.api.messages(id, { pageToken: window.nextCursor });
418
497
  if (!active())
419
498
  return;
420
- found.push(...page.items);
499
+ found.push(...window.items);
421
500
  }
422
501
  const present = new Set(found.map((m) => m.id));
423
502
  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
- }
503
+ this.dispatch(id, { type: 'page', page: first });
504
+ this.submissions.observe(id, found);
505
+ this.historyMeta(id).loadedThrough = first.lastSeq;
506
+ this.historyMeta(id).error = undefined;
442
507
  if (!active())
443
508
  return;
444
509
  const changed = found.find((m) => m.role === 'user' &&
@@ -469,6 +534,13 @@ export class ChatController {
469
534
  ? chat.messages.at(-1)?.id
470
535
  : undefined;
471
536
  chat.messages = mergeUIMessages(chat.messages.filter((m) => !missing.has(m.id) || m.id === activeId), found, activeId);
537
+ const deferred = this.deferredReads.get(id);
538
+ for (const message of found)
539
+ if ((message.metadata?.revision ?? 0) >=
540
+ (this.messageVersions.get(`${id}:${message.id}`) ?? 0))
541
+ deferred?.delete(message.id);
542
+ if (deferred?.size === 0)
543
+ this.deferredReads.delete(id);
472
544
  if (window)
473
545
  this.historyMeta(id).page = {
474
546
  ...this.historyMeta(id).page,
@@ -564,9 +636,29 @@ export class ChatController {
564
636
  const found = [];
565
637
  const active = this.historyLifetime(id);
566
638
  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
- });
639
+ const selection = ids.slice(offset, offset + 100);
640
+ const key = JSON.stringify([id, [...selection].sort()]);
641
+ let pendingRead = this.messageReads.get(key);
642
+ const shared = Boolean(pendingRead);
643
+ if (!pendingRead) {
644
+ pendingRead = this.api.messages(id, { ids: selection.join(',') });
645
+ this.messageReads.set(key, pendingRead);
646
+ const clear = () => {
647
+ if (this.messageReads.get(key) === pendingRead)
648
+ this.messageReads.delete(key);
649
+ };
650
+ void pendingRead.then(clear, clear);
651
+ }
652
+ const page = await pendingRead;
653
+ if (shared &&
654
+ active() &&
655
+ page.items.some((message) => (message.metadata?.revision ?? 0) <
656
+ (this.messageVersions.get(`${id}:${message.id}`) ?? 0))) {
657
+ // A revision announced during the older read needs a causal trailing read, never a cancellation.
658
+ const saved = await this.reread(id, selection);
659
+ found.push(...saved);
660
+ continue;
661
+ }
570
662
  if (!active())
571
663
  return [];
572
664
  found.push(...page.items);
@@ -583,6 +675,42 @@ export class ChatController {
583
675
  }
584
676
  return found;
585
677
  }
678
+ /** A cursor announcement shares the current read; only an actual gap needs a trailing page. */
679
+ messagesChanged(id, lastSeq) {
680
+ if (this.disposed || !this.watches.has(id))
681
+ return Promise.resolve();
682
+ const pending = this.catchups.get(id);
683
+ if (pending) {
684
+ pending.lastSeq = Math.max(pending.lastSeq, lastSeq);
685
+ return pending.task;
686
+ }
687
+ const active = this.historyLifetime(id);
688
+ const owner = { lastSeq, task: Promise.resolve() };
689
+ const run = async () => {
690
+ const reading = this.loads.get(id);
691
+ if (reading)
692
+ await reading;
693
+ while (active() &&
694
+ (!this.history(id).state.loaded ||
695
+ this.history(id).loadedThrough < owner.lastSeq)) {
696
+ const before = this.history(id).loadedThrough;
697
+ await this.loadHistory(id);
698
+ if (this.history(id).error)
699
+ throw this.history(id).error;
700
+ if (this.history(id).loadedThrough <= before)
701
+ break;
702
+ }
703
+ };
704
+ owner.task = run().finally(() => {
705
+ if (this.catchups.get(id) === owner)
706
+ this.catchups.delete(id);
707
+ });
708
+ this.catchups.set(id, owner);
709
+ return owner.task;
710
+ }
711
+ isWatching(id) {
712
+ return this.watches.has(id);
713
+ }
586
714
  watch(id) {
587
715
  let watch = this.watches.get(id);
588
716
  if (!watch) {
@@ -592,11 +720,15 @@ export class ChatController {
592
720
  return;
593
721
  const detail = agentsQueryClient().getQueryData(chatKeys.conversation(id));
594
722
  const current = this.watches.get(id);
595
- const interval = detail?.execution?.status === 'running' ? 5000 : 30000;
723
+ const status = this.chats.get(id)?.status;
724
+ const busy = chatIsRunning(detail ?? null) ||
725
+ status === 'submitted' ||
726
+ status === 'streaming';
727
+ const interval = busy ? 5000 : 30000;
596
728
  if (Date.now() - current.last < interval)
597
729
  return;
598
730
  current.last = Date.now();
599
- void this.refresh(id).catch(() => { });
731
+ void this.refresh(id, true, !busy, false).catch(() => { });
600
732
  }, 5000);
601
733
  watch = { refs: 0, timer, last: Date.now() };
602
734
  this.watches.set(id, watch);
@@ -611,12 +743,24 @@ export class ChatController {
611
743
  };
612
744
  }
613
745
  reconnect() {
614
- for (const id of this.watches.keys())
615
- void this.refresh(id)
746
+ this.invalidateLists();
747
+ for (const id of this.watches.keys()) {
748
+ if (this.reconnects.has(id))
749
+ continue;
750
+ const task = this.refresh(id)
616
751
  .then(() => this.resume(id))
617
- .catch(() => { });
752
+ .catch(() => { })
753
+ .finally(() => {
754
+ if (this.reconnects.get(id) === task)
755
+ this.reconnects.delete(id);
756
+ });
757
+ this.reconnects.set(id, task);
758
+ }
618
759
  }
619
760
  messageUpdated(id, messageId, revision) {
761
+ const saved = this.chat(id).messages.find((message) => message.id === messageId);
762
+ if ((saved?.metadata?.revision ?? -1) >= revision)
763
+ return;
620
764
  const key = `${id}:${messageId}`;
621
765
  if (((this.messageVersions.get(key) ?? -1) >= revision &&
622
766
  !this.deferredReads.get(id)?.has(messageId)) ||
@@ -634,9 +778,17 @@ export class ChatController {
634
778
  queue.task = (async () => {
635
779
  const attempted = new Set();
636
780
  try {
781
+ // Let a concurrent cursor read or SDK finish supply the same durable revision first.
782
+ await new Promise((resolve) => setTimeout(resolve, CHAT_INVALIDATION_MS));
783
+ const reading = this.loads.get(id);
784
+ if (reading)
785
+ await reading;
637
786
  while (active() && queue.pending.size) {
638
- const ids = [...queue.pending];
787
+ const ids = [...queue.pending].filter((messageId) => (this.chat(id).messages.find((message) => message.id === messageId)?.metadata?.revision ?? -1) <
788
+ (this.messageVersions.get(`${id}:${messageId}`) ?? 0));
639
789
  queue.pending.clear();
790
+ if (!ids.length)
791
+ continue;
640
792
  ids.forEach((id) => attempted.add(id));
641
793
  await this.reread(id, ids);
642
794
  }
@@ -774,6 +926,19 @@ export class ChatController {
774
926
  ?.id ??
775
927
  '');
776
928
  }
929
+ followStream(id) {
930
+ const count = (this.streamFollowers.get(id) ?? 0) + 1;
931
+ this.streamFollowers.set(id, count);
932
+ if (count === 1)
933
+ this.receive(id);
934
+ return () => {
935
+ const remaining = (this.streamFollowers.get(id) ?? 1) - 1;
936
+ if (remaining === 0)
937
+ this.streamFollowers.delete(id);
938
+ else
939
+ this.streamFollowers.set(id, remaining);
940
+ };
941
+ }
777
942
  receive(id, known) {
778
943
  if (this.disposed)
779
944
  return;
@@ -793,7 +958,10 @@ export class ChatController {
793
958
  return;
794
959
  if (!chatIsRunning(detail) || detail.execution?.waitingApproval) {
795
960
  const chat = this.chats.get(id);
796
- if (chat?.status !== 'submitted' && chat?.status !== 'streaming')
961
+ if (chat?.status !== 'submitted' &&
962
+ chat?.status !== 'streaming' &&
963
+ (!this.history(id).state.loaded ||
964
+ this.history(id).loadedThrough < (detail.lastSeq ?? 0)))
797
965
  await this.loadHistory(id);
798
966
  return;
799
967
  }
@@ -870,7 +1038,13 @@ export class ChatController {
870
1038
  recovery.phase = 'interrupted';
871
1039
  recovery.error = chat.error;
872
1040
  }
873
- await this.refresh(id);
1041
+ if (agentsQueryClient()
1042
+ .getQueryCache()
1043
+ .find({ queryKey: chatKeys.conversation(id), exact: true })
1044
+ ?.isActive())
1045
+ this.scheduleRefresh(id);
1046
+ else
1047
+ await this.refresh(id);
874
1048
  }
875
1049
  }
876
1050
  catch (error) {
@@ -947,9 +1121,63 @@ export class ChatController {
947
1121
  if (active())
948
1122
  await this.refresh(id);
949
1123
  }
950
- async refresh(id) {
1124
+ /** One detail request shared by the query observer and message reconciliation. */
1125
+ readDetail(id, fresh = false) {
1126
+ const pending = this.detailReads.get(id);
1127
+ if (pending)
1128
+ return fresh
1129
+ ? pending.catch(() => undefined).then(() => this.readDetail(id))
1130
+ : pending;
1131
+ const task = this.api.conversation(id);
1132
+ this.detailReads.set(id, task);
1133
+ const clear = () => {
1134
+ if (this.detailReads.get(id) === task)
1135
+ this.detailReads.delete(id);
1136
+ };
1137
+ void task.then(clear, clear);
1138
+ return task;
1139
+ }
1140
+ refresh(id, fresh = true, idleProbe = false, reconcileHistory = true) {
1141
+ if (this.disposed)
1142
+ return Promise.resolve();
1143
+ const pending = this.refreshes.get(id);
1144
+ if (pending) {
1145
+ if (fresh) {
1146
+ pending.trailing = true;
1147
+ this.nextDetailVersion(id);
1148
+ }
1149
+ return pending.task;
1150
+ }
1151
+ const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion;
1152
+ const owner = { trailing: false, task: Promise.resolve() };
1153
+ const run = async () => {
1154
+ do {
1155
+ owner.trailing = false;
1156
+ await this.refreshOnce(id, fresh, idleProbe, undefined, reconcileHistory);
1157
+ fresh = true;
1158
+ idleProbe = false;
1159
+ } while (owner.trailing && active());
1160
+ };
1161
+ owner.task = run().finally(() => {
1162
+ if (this.refreshes.get(id) === owner)
1163
+ this.refreshes.delete(id);
1164
+ });
1165
+ this.refreshes.set(id, owner);
1166
+ return owner.task;
1167
+ }
1168
+ async refreshOnce(id, fresh, idleProbe = false, snapshot, reconcileHistory = true) {
951
1169
  if (this.disposed)
952
1170
  return;
1171
+ const waiting = this.lifetime();
1172
+ if ((!fresh || !reconcileHistory) && !snapshot) {
1173
+ const query = agentsQueryClient()
1174
+ .getQueryCache()
1175
+ .find({ queryKey: chatKeys.conversation(id), exact: true });
1176
+ if (query?.state.fetchStatus === 'fetching' && query.promise)
1177
+ snapshot = (await query.promise);
1178
+ if (!waiting())
1179
+ return;
1180
+ }
953
1181
  const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion, ordered = this.detailRequest(id);
954
1182
  const submittedOwner = this.chat(id);
955
1183
  const submittedInput = submittedOwner.status === 'submitted' && !this.admissions.has(id)
@@ -957,16 +1185,62 @@ export class ChatController {
957
1185
  .reverse()
958
1186
  .find((message) => message.role === 'user' && message.metadata?.seq !== undefined)
959
1187
  : undefined;
960
- const detail = await this.api.conversation(id);
1188
+ const query = agentsQueryClient()
1189
+ .getQueryCache()
1190
+ .find({ queryKey: chatKeys.conversation(id), exact: true });
1191
+ const cached = !fresh && query?.isActive() && query.state.fetchStatus === 'idle'
1192
+ ? query.state.data
1193
+ : undefined;
1194
+ const previous = query?.state.data;
1195
+ const detail = snapshot ?? cached ?? (await this.readDetail(id, fresh));
1196
+ if (!active())
1197
+ return;
1198
+ if (idleProbe &&
1199
+ previous &&
1200
+ !chatIsRunning(detail) &&
1201
+ detail.lastSeq !== undefined &&
1202
+ this.history(id).state.loaded &&
1203
+ !this.history(id).error &&
1204
+ this.history(id).loadedThrough >= detail.lastSeq &&
1205
+ hashKey([previous]) === hashKey([detail])) {
1206
+ // The durable cursor is current. Still reread revisions of the latest assistant response: a same-sequence
1207
+ // update may have lost its announcement, even though the conversation snapshot did not change.
1208
+ const latest = [...this.chat(id).messages]
1209
+ .reverse()
1210
+ .find((message) => message.role === 'assistant');
1211
+ const ids = [
1212
+ ...new Set([
1213
+ ...(this.deferredReads.get(id) ?? []),
1214
+ ...(latest ? [latest.id] : []),
1215
+ ]),
1216
+ ];
1217
+ if (ids.length)
1218
+ await this.reread(id, ids);
1219
+ if (active() && ordered()) {
1220
+ this.remember(detail, false);
1221
+ this.publish();
1222
+ }
1223
+ return;
1224
+ }
961
1225
  if (!active())
962
1226
  return;
963
- if (ordered())
1227
+ const reconciled = fresh && !snapshot && reconcileHistory && ordered();
1228
+ if (reconciled)
964
1229
  await this.reconcile(id, detail);
965
1230
  if (!active())
966
1231
  return;
967
1232
  // A newer detail query must not discard this refresh's message catch-up.
968
1233
  // loadHistory coalesces overlapping reads independently of detail ordering.
969
- await this.loadHistory(id, false, true);
1234
+ const beforeThrough = this.history(id).loadedThrough;
1235
+ if ((!snapshot && !reconciled && fresh && detail.lastSeq === undefined) ||
1236
+ (reconciled && !ordered()) ||
1237
+ !this.history(id).state.loaded ||
1238
+ this.history(id).loadedThrough < (detail.lastSeq ?? 0)) {
1239
+ if (!reconcileHistory && detail.lastSeq !== undefined)
1240
+ await this.messagesChanged(id, detail.lastSeq);
1241
+ else
1242
+ await this.loadHistory(id, false, fresh);
1243
+ }
970
1244
  if (!active())
971
1245
  return;
972
1246
  const history = this.history(id);
@@ -981,14 +1255,23 @@ export class ChatController {
981
1255
  const revisable = [
982
1256
  ...new Set([
983
1257
  ...(this.deferredReads.get(id) ?? []),
984
- ...(detail.execution?.messageId
1258
+ ...(detail.execution?.messageId &&
1259
+ ((!snapshot &&
1260
+ !reconciled &&
1261
+ this.history(id).loadedThrough === beforeThrough) ||
1262
+ (this.chat(id).messages.find((m) => m.id === detail.execution?.messageId)?.metadata?.revision ?? -1) < (detail.execution.revision ?? 0))
985
1263
  ? [detail.execution.messageId]
986
- : latest
1264
+ : !snapshot &&
1265
+ !reconciled &&
1266
+ this.history(id).loadedThrough === beforeThrough &&
1267
+ latest
987
1268
  ? [latest.id]
988
1269
  : []),
989
1270
  ]),
990
1271
  ];
991
- const saved = revisable.length ? await this.reread(id, revisable) : [];
1272
+ const saved = revisable.length
1273
+ ? await this.reread(id, revisable)
1274
+ : [...this.chat(id).messages];
992
1275
  if (!active() || !ordered())
993
1276
  return;
994
1277
  const owner = this.chat(id), receiving = owner.messages.at(-1);
@@ -1023,7 +1306,8 @@ export class ChatController {
1023
1306
  }
1024
1307
  if (!active() || !ordered())
1025
1308
  return;
1026
- this.remember(detail, false);
1309
+ if (!snapshot)
1310
+ this.remember(detail, false);
1027
1311
  const chat = this.chat(id);
1028
1312
  if (detail.execution?.status === 'idle') {
1029
1313
  this.recoveries.delete(id);
@@ -1033,6 +1317,13 @@ export class ChatController {
1033
1317
  this.publish();
1034
1318
  }
1035
1319
  scheduleRefresh(id) {
1320
+ if (agentsQueryClient()
1321
+ .getQueryCache()
1322
+ .find({ queryKey: chatKeys.conversation(id), exact: true })
1323
+ ?.isActive()) {
1324
+ this.invalidateQuery(chatKeys.conversation(id), true);
1325
+ return;
1326
+ }
1036
1327
  if (this.completionReads.has(id))
1037
1328
  return;
1038
1329
  this.completionReads.add(id);
@@ -1042,14 +1333,16 @@ export class ChatController {
1042
1333
  void this.refresh(id).catch(() => { });
1043
1334
  });
1044
1335
  }
1045
- observeDetail(detail, refresh = true) {
1336
+ observeDetail(detail, refresh = true, synchronizeLists = true) {
1337
+ if (synchronizeLists)
1338
+ this.syncLists(detail);
1046
1339
  if (detail.execution?.status === 'running')
1047
1340
  this.receive(detail.id, detail);
1048
1341
  if (!chatIsRunning(detail)) {
1049
1342
  if (refresh &&
1050
1343
  (this.chats.get(detail.id)?.status === 'streaming' ||
1051
1344
  this.chats.get(detail.id)?.status === 'submitted'))
1052
- this.scheduleRefresh(detail.id);
1345
+ void this.refreshOnce(detail.id, false, false, detail).catch(() => { });
1053
1346
  this.stopRequests.delete(detail.id);
1054
1347
  this.recoveries.delete(detail.id);
1055
1348
  this.publish();
@@ -1088,29 +1381,103 @@ export class ChatController {
1088
1381
  }
1089
1382
  remember(detail, refresh = true) {
1090
1383
  this.nextDetailVersion(detail.id);
1091
- this.observeDetail(detail, refresh);
1092
- agentsQueryClient().setQueryData(chatKeys.conversation(detail.id), detail);
1093
- void agentsQueryClient().invalidateQueries({ queryKey: chatKeys.lists });
1384
+ // A reconnect already owns a fresh list read; its later history response must not schedule another one.
1385
+ this.observeDetail(detail, refresh, !this.reconnects.has(detail.id));
1386
+ const client = agentsQueryClient();
1387
+ client.setQueryData(chatKeys.conversation(detail.id), detail);
1388
+ }
1389
+ /** The HTTP receipt supplies this tab's snapshot; retain a newer read position for a trailing read. */
1390
+ observeReceipt(id, seenSeq) {
1391
+ const update = (item) => {
1392
+ if (item.id !== id || (item.seenSeq ?? -1) >= seenSeq)
1393
+ return item;
1394
+ return {
1395
+ ...item,
1396
+ seenSeq,
1397
+ read: seenSeq >= (item.lastSeq ?? 0) || item.read,
1398
+ needsYou: item.needsYou &&
1399
+ item.needsYou.reason !== 'confirm' &&
1400
+ item.needsYou.seq <= seenSeq
1401
+ ? null
1402
+ : item.needsYou,
1403
+ };
1404
+ };
1405
+ agentsQueryClient().setQueriesData({ queryKey: chatKeys.lists }, (old) => !old
1406
+ ? old
1407
+ : Array.isArray(old)
1408
+ ? old.map(update)
1409
+ : {
1410
+ ...old,
1411
+ pages: old.pages.map((page) => ({
1412
+ ...page,
1413
+ items: page.items.map(update),
1414
+ })),
1415
+ });
1416
+ }
1417
+ deferReadReceipt(id, seenSeq, refresh) {
1418
+ const pending = this.pendingReceipts.get(id);
1419
+ if (!pending)
1420
+ return false;
1421
+ pending.seenSeq = Math.max(pending.seenSeq, seenSeq);
1422
+ pending.refresh = refresh;
1423
+ return true;
1094
1424
  }
1095
1425
  async markRead(id) {
1426
+ const client = agentsQueryClient();
1427
+ const key = chatKeys.conversation(id);
1428
+ const before = client.getQueryData(key);
1096
1429
  const active = this.lifetime(), commandVersion = this.commandVersions.get(id), receiptVersion = (this.receiptVersions.get(id) ?? 0) + 1;
1097
1430
  // Detail reads must not suppress the read transition needed by a viewed conversation.
1098
1431
  this.receiptVersions.set(id, receiptVersion);
1432
+ const pending = { seenSeq: -1 };
1433
+ this.pendingReceipts.set(id, pending);
1099
1434
  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 });
1435
+ try {
1436
+ const detail = await this.api.markRead(id);
1437
+ if (active() &&
1438
+ this.commandVersions.get(id) === commandVersion &&
1439
+ this.receiptVersions.get(id) === receiptVersion) {
1440
+ this.observeReceipt(id, detail.seenSeq ?? 0);
1441
+ // Read receipts do not change execution or invalidate pending history.
1442
+ this.nextDetailVersion(id);
1443
+ client.setQueryData(key, (current) => {
1444
+ if (!current ||
1445
+ current === before ||
1446
+ (current.lastSeq ?? 0) < (detail.lastSeq ?? 0) ||
1447
+ (current.lastSeq === detail.lastSeq &&
1448
+ current.updatedAt < detail.updatedAt))
1449
+ return detail;
1450
+ // A later snapshot may already contain the next reply or a metadata change.
1451
+ const seenSeq = Math.max(current.seenSeq ?? 0, detail.seenSeq ?? 0);
1452
+ return {
1453
+ ...current,
1454
+ seenSeq,
1455
+ read: seenSeq >= (current.lastSeq ?? 0) || current.read,
1456
+ needsYou: current.needsYou &&
1457
+ current.needsYou.reason !== 'confirm' &&
1458
+ current.needsYou.seq <= seenSeq
1459
+ ? null
1460
+ : current.needsYou,
1461
+ };
1462
+ });
1463
+ }
1464
+ return detail;
1465
+ }
1466
+ finally {
1467
+ if (this.pendingReceipts.get(id) === pending) {
1468
+ this.pendingReceipts.delete(id);
1469
+ const current = agentsQueryClient().getQueryData(chatKeys.conversation(id));
1470
+ if (active() && pending.seenSeq > (current?.seenSeq ?? -1))
1471
+ pending.refresh?.();
1472
+ }
1108
1473
  }
1109
- return detail;
1110
1474
  }
1111
1475
  async command(id, request, detail) {
1112
1476
  const active = this.lifetime(), version = (this.commandVersions.get(id) ?? 0) + 1;
1113
1477
  this.commandVersions.set(id, version);
1478
+ // A command replaces the snapshot generation; its reads must not wait on obsolete HTTP.
1479
+ this.detailReads.delete(id);
1480
+ this.refreshes.delete(id);
1114
1481
  this.nextDetailVersion(id);
1115
1482
  this.invalidateHistoryReads(id);
1116
1483
  const recovery = this.recoveries.get(id);
@@ -1143,6 +1510,8 @@ export class ChatController {
1143
1510
  this.disposed = false;
1144
1511
  }
1145
1512
  invalidate() {
1513
+ this.invalidations?.stop();
1514
+ this.invalidations = undefined;
1146
1515
  if (this.disposed)
1147
1516
  return;
1148
1517
  this.disposed = true;
@@ -1157,6 +1526,9 @@ export class ChatController {
1157
1526
  this.historyEpochs.clear();
1158
1527
  this.replacements.clear();
1159
1528
  this.reconciles.clear();
1529
+ this.streamFollowers.clear();
1530
+ this.detailReads.clear();
1531
+ this.refreshes.clear();
1160
1532
  for (const watch of this.watches.values())
1161
1533
  clearInterval(watch.timer);
1162
1534
  this.watches.clear();
@@ -1166,14 +1538,19 @@ export class ChatController {
1166
1538
  this.completionReads.clear();
1167
1539
  this.admissions.clear();
1168
1540
  this.loads.clear();
1541
+ this.messageReads.clear();
1542
+ this.catchups.clear();
1543
+ this.reconnects.clear();
1169
1544
  this.refreshAgain.clear();
1170
1545
  this.commandVersions.clear();
1171
1546
  this.receiptVersions.clear();
1547
+ this.pendingReceipts.clear();
1172
1548
  this.stopRequests.clear();
1173
1549
  this.approvalRequests.clear();
1174
1550
  this.recoveries.clear();
1175
1551
  this.submissions.invalidate();
1176
- agentsQueryClient().removeQueries({ queryKey: chatKeys.all });
1552
+ // All plugin snapshots are viewer-scoped, including fresh agent options.
1553
+ agentsQueryClient().removeQueries({ queryKey: agentsKeys.all });
1177
1554
  }
1178
1555
  dispose() {
1179
1556
  this.invalidate();
@@ -1192,10 +1569,7 @@ export function ChatClientProvider({ children, }) {
1192
1569
  controller.activate();
1193
1570
  const lifetimes = lifetimesRef.current;
1194
1571
  lifetimes.set(controller, (lifetimes.get(controller) ?? 0) + 1);
1195
- const reconnect = () => controller.reconnect();
1196
- window.addEventListener('online', reconnect);
1197
1572
  return () => {
1198
- window.removeEventListener('online', reconnect);
1199
1573
  controller.invalidate();
1200
1574
  lifetimes.set(controller, (lifetimes.get(controller) ?? 1) - 1);
1201
1575
  queueMicrotask(() => {
@@ -1216,12 +1590,13 @@ export function useChatController() {
1216
1590
  }
1217
1591
  export function useChatStream(id) {
1218
1592
  const controller = useChatController();
1593
+ const ready = useRealtimeReadiness([CONVERSATIONS_TOPIC]);
1219
1594
  useSDKChat({ chat: controller.chat(id ?? 'draft') });
1220
1595
  useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot);
1221
1596
  useEffect(() => {
1222
- if (id)
1223
- controller.receive(id);
1224
- }, [controller, id]);
1597
+ if (ready && id)
1598
+ return controller.followStream(id);
1599
+ }, [controller, id, ready]);
1225
1600
  return id ? controller.state(id) : undefined;
1226
1601
  }
1227
1602
  export function useChatStreamState(id) {