@nocobase/app-plugin-agents 1.0.0-beta.29 → 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 (300) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +8 -19
  3. package/dist/client/api/client.d.ts +2 -2
  4. package/dist/client/api/client.d.ts.map +1 -1
  5. package/dist/client/api/client.js +2 -2
  6. package/dist/client/api/client.js.map +1 -1
  7. package/dist/client/api/runners.d.ts +2 -2
  8. package/dist/client/api/runners.d.ts.map +1 -1
  9. package/dist/client/api/runners.js +5 -2
  10. package/dist/client/api/runners.js.map +1 -1
  11. package/dist/client/chat/api.d.ts +1 -1
  12. package/dist/client/chat/api.d.ts.map +1 -1
  13. package/dist/client/chat/api.js +6 -2
  14. package/dist/client/chat/api.js.map +1 -1
  15. package/dist/client/chat/coalesced-invalidation.d.ts +3 -8
  16. package/dist/client/chat/coalesced-invalidation.d.ts.map +1 -1
  17. package/dist/client/chat/coalesced-invalidation.js +16 -21
  18. package/dist/client/chat/coalesced-invalidation.js.map +1 -1
  19. package/dist/client/chat/controller.d.ts +31 -7
  20. package/dist/client/chat/controller.d.ts.map +1 -1
  21. package/dist/client/chat/controller.js +616 -120
  22. package/dist/client/chat/controller.js.map +1 -1
  23. package/dist/client/chat/provider.d.ts.map +1 -1
  24. package/dist/client/chat/provider.js +7 -4
  25. package/dist/client/chat/provider.js.map +1 -1
  26. package/dist/client/chat/submissions.d.ts +2 -0
  27. package/dist/client/chat/submissions.d.ts.map +1 -1
  28. package/dist/client/chat/submissions.js +1 -0
  29. package/dist/client/chat/submissions.js.map +1 -1
  30. package/dist/client/chat/use-chat.d.ts +4 -2
  31. package/dist/client/chat/use-chat.d.ts.map +1 -1
  32. package/dist/client/chat/use-chat.js +84 -55
  33. package/dist/client/chat/use-chat.js.map +1 -1
  34. package/dist/client/components/ag-tag-tones.d.ts +7 -0
  35. package/dist/client/components/ag-tag-tones.d.ts.map +1 -0
  36. package/dist/client/components/ag-tag-tones.js +14 -0
  37. package/dist/client/components/ag-tag-tones.js.map +1 -0
  38. package/dist/client/components/ag-tag.d.ts +2 -1
  39. package/dist/client/components/ag-tag.d.ts.map +1 -1
  40. package/dist/client/components/ag-tag.js +1 -13
  41. package/dist/client/components/ag-tag.js.map +1 -1
  42. package/dist/client/components/quota-status.d.ts +2 -1
  43. package/dist/client/components/quota-status.d.ts.map +1 -1
  44. package/dist/client/components/quota-status.js +2 -2
  45. package/dist/client/components/quota-status.js.map +1 -1
  46. package/dist/client/components/ui/pagination.d.ts +6 -0
  47. package/dist/client/components/ui/pagination.d.ts.map +1 -0
  48. package/dist/client/components/ui/pagination.js +13 -0
  49. package/dist/client/components/ui/pagination.js.map +1 -0
  50. package/dist/client/hooks/use-notify.d.ts.map +1 -1
  51. package/dist/client/hooks/use-notify.js +4 -0
  52. package/dist/client/hooks/use-notify.js.map +1 -1
  53. package/dist/client/hooks/use-realtime-topic.d.ts +1 -1
  54. package/dist/client/hooks/use-realtime-topic.d.ts.map +1 -1
  55. package/dist/client/hooks/use-realtime-topic.js +8 -4
  56. package/dist/client/hooks/use-realtime-topic.js.map +1 -1
  57. package/dist/client/kit.d.ts.map +1 -1
  58. package/dist/client/kit.js +30 -5
  59. package/dist/client/kit.js.map +1 -1
  60. package/dist/client/locales/de-DE.d.ts.map +1 -1
  61. package/dist/client/locales/de-DE.js +26 -10
  62. package/dist/client/locales/de-DE.js.map +1 -1
  63. package/dist/client/locales/en-US.d.ts +11 -0
  64. package/dist/client/locales/en-US.d.ts.map +1 -1
  65. package/dist/client/locales/en-US.js +21 -10
  66. package/dist/client/locales/en-US.js.map +1 -1
  67. package/dist/client/locales/es-ES.d.ts.map +1 -1
  68. package/dist/client/locales/es-ES.js +26 -10
  69. package/dist/client/locales/es-ES.js.map +1 -1
  70. package/dist/client/locales/fr-FR.d.ts.map +1 -1
  71. package/dist/client/locales/fr-FR.js +26 -10
  72. package/dist/client/locales/fr-FR.js.map +1 -1
  73. package/dist/client/locales/id-ID.d.ts.map +1 -1
  74. package/dist/client/locales/id-ID.js +26 -10
  75. package/dist/client/locales/id-ID.js.map +1 -1
  76. package/dist/client/locales/ja-JP.d.ts.map +1 -1
  77. package/dist/client/locales/ja-JP.js +26 -10
  78. package/dist/client/locales/ja-JP.js.map +1 -1
  79. package/dist/client/locales/pt-BR.d.ts.map +1 -1
  80. package/dist/client/locales/pt-BR.js +26 -10
  81. package/dist/client/locales/pt-BR.js.map +1 -1
  82. package/dist/client/locales/runtimes.en-US.d.ts +5 -0
  83. package/dist/client/locales/runtimes.en-US.d.ts.map +1 -1
  84. package/dist/client/locales/runtimes.en-US.js +5 -0
  85. package/dist/client/locales/runtimes.en-US.js.map +1 -1
  86. package/dist/client/locales/runtimes.zh-CN.d.ts.map +1 -1
  87. package/dist/client/locales/runtimes.zh-CN.js +5 -0
  88. package/dist/client/locales/runtimes.zh-CN.js.map +1 -1
  89. package/dist/client/locales/vi-VN.d.ts.map +1 -1
  90. package/dist/client/locales/vi-VN.js +26 -10
  91. package/dist/client/locales/vi-VN.js.map +1 -1
  92. package/dist/client/locales/zh-CN.d.ts.map +1 -1
  93. package/dist/client/locales/zh-CN.js +21 -10
  94. package/dist/client/locales/zh-CN.js.map +1 -1
  95. package/dist/client/locales/zh-TW.d.ts.map +1 -1
  96. package/dist/client/locales/zh-TW.js +26 -10
  97. package/dist/client/locales/zh-TW.js.map +1 -1
  98. package/dist/client/model-suggestion.d.ts +2 -0
  99. package/dist/client/model-suggestion.d.ts.map +1 -1
  100. package/dist/client/model-suggestion.js +27 -2
  101. package/dist/client/model-suggestion.js.map +1 -1
  102. package/dist/client/pages/agents/agent-dialog.d.ts.map +1 -1
  103. package/dist/client/pages/agents/agent-dialog.js +10 -8
  104. package/dist/client/pages/agents/agent-dialog.js.map +1 -1
  105. package/dist/client/pages/agents/available-models-drawer.d.ts.map +1 -1
  106. package/dist/client/pages/agents/available-models-drawer.js +2 -1
  107. package/dist/client/pages/agents/available-models-drawer.js.map +1 -1
  108. package/dist/client/pages/agents/good-at-templates.d.ts +8 -0
  109. package/dist/client/pages/agents/good-at-templates.d.ts.map +1 -0
  110. package/dist/client/pages/agents/good-at-templates.js +29 -0
  111. package/dist/client/pages/agents/good-at-templates.js.map +1 -0
  112. package/dist/client/pages/runtimes/runner-cells.d.ts.map +1 -1
  113. package/dist/client/pages/runtimes/runner-cells.js +3 -1
  114. package/dist/client/pages/runtimes/runner-cells.js.map +1 -1
  115. package/dist/client/pages/runtimes/runner-pagination.d.ts +15 -0
  116. package/dist/client/pages/runtimes/runner-pagination.d.ts.map +1 -0
  117. package/dist/client/pages/runtimes/runner-pagination.js +9 -0
  118. package/dist/client/pages/runtimes/runner-pagination.js.map +1 -0
  119. package/dist/client/pages/runtimes/runner-runs.d.ts +3 -2
  120. package/dist/client/pages/runtimes/runner-runs.d.ts.map +1 -1
  121. package/dist/client/pages/runtimes/runner-runs.js +38 -15
  122. package/dist/client/pages/runtimes/runner-runs.js.map +1 -1
  123. package/dist/client/pages/runtimes/runner-sheet.js +1 -1
  124. package/dist/client/pages/runtimes/runner-sheet.js.map +1 -1
  125. package/dist/client/pages/runtimes/runner-workspaces.d.ts.map +1 -1
  126. package/dist/client/pages/runtimes/runner-workspaces.js +7 -3
  127. package/dist/client/pages/runtimes/runner-workspaces.js.map +1 -1
  128. package/dist/client/runs/refresh-run.d.ts +4 -0
  129. package/dist/client/runs/refresh-run.d.ts.map +1 -0
  130. package/dist/client/runs/refresh-run.js +8 -0
  131. package/dist/client/runs/refresh-run.js.map +1 -0
  132. package/dist/client/runs/run-hooks.d.ts.map +1 -1
  133. package/dist/client/runs/run-hooks.js +2 -1
  134. package/dist/client/runs/run-hooks.js.map +1 -1
  135. package/dist/database/migrations/.manifest.json +8 -0
  136. package/dist/database/migrations/202611010003_ag_runner_run_history_index.d.ts +4 -0
  137. package/dist/database/migrations/202611010003_ag_runner_run_history_index.d.ts.map +1 -0
  138. package/dist/database/migrations/202611010003_ag_runner_run_history_index.js +17 -0
  139. package/dist/database/migrations/202611010003_ag_runner_run_history_index.js.map +1 -0
  140. package/dist/database/migrations/202611070001_ag_conversation_project.d.ts +4 -0
  141. package/dist/database/migrations/202611070001_ag_conversation_project.d.ts.map +1 -0
  142. package/dist/database/migrations/202611070001_ag_conversation_project.js +17 -0
  143. package/dist/database/migrations/202611070001_ag_conversation_project.js.map +1 -0
  144. package/dist/package.json +4 -6
  145. package/dist/server/composition.d.ts +0 -17
  146. package/dist/server/composition.d.ts.map +1 -1
  147. package/dist/server/composition.js +7 -20
  148. package/dist/server/composition.js.map +1 -1
  149. package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
  150. package/dist/server/core/conversations/conversation.service.js +174 -22
  151. package/dist/server/core/conversations/conversation.service.js.map +1 -1
  152. package/dist/server/core/conversations/conversation.store.d.ts +1 -0
  153. package/dist/server/core/conversations/conversation.store.d.ts.map +1 -1
  154. package/dist/server/core/conversations/conversation.store.js.map +1 -1
  155. package/dist/server/core/conversations/index.d.ts +1 -1
  156. package/dist/server/core/conversations/index.d.ts.map +1 -1
  157. package/dist/server/core/conversations/index.js.map +1 -1
  158. package/dist/server/core/conversations/links.d.ts +3 -0
  159. package/dist/server/core/conversations/links.d.ts.map +1 -1
  160. package/dist/server/core/conversations/links.js +33 -0
  161. package/dist/server/core/conversations/links.js.map +1 -1
  162. package/dist/server/core/conversations/ports.d.ts +14 -0
  163. package/dist/server/core/conversations/ports.d.ts.map +1 -1
  164. package/dist/server/core/runs/claim.d.ts +7 -1
  165. package/dist/server/core/runs/claim.d.ts.map +1 -1
  166. package/dist/server/core/runs/claim.js +69 -17
  167. package/dist/server/core/runs/claim.js.map +1 -1
  168. package/dist/server/core/runs/ports.d.ts +2 -0
  169. package/dist/server/core/runs/ports.d.ts.map +1 -1
  170. package/dist/server/core/runs/ports.js.map +1 -1
  171. package/dist/server/core/runs/run.service.d.ts +6 -1
  172. package/dist/server/core/runs/run.service.d.ts.map +1 -1
  173. package/dist/server/core/runs/run.service.js +29 -1
  174. package/dist/server/core/runs/run.service.js.map +1 -1
  175. package/dist/server/core/runs/run.store.d.ts +1 -1
  176. package/dist/server/core/runs/run.store.d.ts.map +1 -1
  177. package/dist/server/core/runs/run.store.js +1 -1
  178. package/dist/server/core/runs/run.store.js.map +1 -1
  179. package/dist/server/core/runs/runner-view.d.ts +8 -4
  180. package/dist/server/core/runs/runner-view.d.ts.map +1 -1
  181. package/dist/server/core/runs/runner-view.js +89 -32
  182. package/dist/server/core/runs/runner-view.js.map +1 -1
  183. package/dist/server/kernel/events.d.ts +2 -0
  184. package/dist/server/kernel/events.d.ts.map +1 -1
  185. package/dist/server/kernel/events.js.map +1 -1
  186. package/dist/server/locales/de-DE.d.ts.map +1 -1
  187. package/dist/server/locales/de-DE.js +5 -0
  188. package/dist/server/locales/de-DE.js.map +1 -1
  189. package/dist/server/locales/en-US.d.ts +7 -1
  190. package/dist/server/locales/en-US.d.ts.map +1 -1
  191. package/dist/server/locales/en-US.js +5 -0
  192. package/dist/server/locales/en-US.js.map +1 -1
  193. package/dist/server/locales/es-ES.d.ts.map +1 -1
  194. package/dist/server/locales/es-ES.js +5 -0
  195. package/dist/server/locales/es-ES.js.map +1 -1
  196. package/dist/server/locales/fr-FR.d.ts.map +1 -1
  197. package/dist/server/locales/fr-FR.js +5 -0
  198. package/dist/server/locales/fr-FR.js.map +1 -1
  199. package/dist/server/locales/id-ID.d.ts.map +1 -1
  200. package/dist/server/locales/id-ID.js +5 -0
  201. package/dist/server/locales/id-ID.js.map +1 -1
  202. package/dist/server/locales/ja-JP.d.ts.map +1 -1
  203. package/dist/server/locales/ja-JP.js +5 -0
  204. package/dist/server/locales/ja-JP.js.map +1 -1
  205. package/dist/server/locales/pt-BR.d.ts.map +1 -1
  206. package/dist/server/locales/pt-BR.js +5 -0
  207. package/dist/server/locales/pt-BR.js.map +1 -1
  208. package/dist/server/locales/vi-VN.d.ts.map +1 -1
  209. package/dist/server/locales/vi-VN.js +5 -0
  210. package/dist/server/locales/vi-VN.js.map +1 -1
  211. package/dist/server/locales/zh-CN.d.ts +2 -2
  212. package/dist/server/locales/zh-CN.d.ts.map +1 -1
  213. package/dist/server/locales/zh-CN.js +5 -0
  214. package/dist/server/locales/zh-CN.js.map +1 -1
  215. package/dist/server/locales/zh-TW.d.ts.map +1 -1
  216. package/dist/server/locales/zh-TW.js +5 -0
  217. package/dist/server/locales/zh-TW.js.map +1 -1
  218. package/dist/server/online/agent/native/backend.d.ts.map +1 -1
  219. package/dist/server/online/agent/native/backend.js +2 -0
  220. package/dist/server/online/agent/native/backend.js.map +1 -1
  221. package/dist/server/online/agent/native/session.d.ts.map +1 -1
  222. package/dist/server/online/agent/native/session.js +3 -1
  223. package/dist/server/online/agent/native/session.js.map +1 -1
  224. package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
  225. package/dist/server/online/agent/runner/backend.js +21 -9
  226. package/dist/server/online/agent/runner/backend.js.map +1 -1
  227. package/dist/server/online/agent/runner/projection.d.ts +1 -1
  228. package/dist/server/online/agent/runner/projection.d.ts.map +1 -1
  229. package/dist/server/online/agent/runner/projection.js +33 -3
  230. package/dist/server/online/agent/runner/projection.js.map +1 -1
  231. package/dist/server/online/agent/service.d.ts.map +1 -1
  232. package/dist/server/online/agent/service.js +3 -0
  233. package/dist/server/online/agent/service.js.map +1 -1
  234. package/dist/server/online/agent/types.d.ts +3 -0
  235. package/dist/server/online/agent/types.d.ts.map +1 -1
  236. package/dist/server/providers/agents.d.ts +5 -9
  237. package/dist/server/providers/agents.d.ts.map +1 -1
  238. package/dist/server/providers/agents.js +12 -13
  239. package/dist/server/providers/agents.js.map +1 -1
  240. package/dist/server/routes/chat.d.ts.map +1 -1
  241. package/dist/server/routes/chat.js +37 -0
  242. package/dist/server/routes/chat.js.map +1 -1
  243. package/dist/server/routes/runners/admin.d.ts.map +1 -1
  244. package/dist/server/routes/runners/admin.js +16 -7
  245. package/dist/server/routes/runners/admin.js.map +1 -1
  246. package/dist/server/routes/runners/runner.d.ts.map +1 -1
  247. package/dist/server/routes/runners/runner.js +6 -4
  248. package/dist/server/routes/runners/runner.js.map +1 -1
  249. package/dist/server/routes/schemas.d.ts +8 -2
  250. package/dist/server/routes/schemas.d.ts.map +1 -1
  251. package/dist/server/routes/schemas.js +46 -3
  252. package/dist/server/routes/schemas.js.map +1 -1
  253. package/dist/server/tokens.d.ts +1 -2
  254. package/dist/server/tokens.d.ts.map +1 -1
  255. package/dist/server/tokens.js.map +1 -1
  256. package/dist/shared/conversations.d.ts +20 -0
  257. package/dist/shared/conversations.d.ts.map +1 -1
  258. package/dist/shared/conversations.js.map +1 -1
  259. package/dist/shared/runners.d.ts +18 -2
  260. package/dist/shared/runners.d.ts.map +1 -1
  261. package/dist/shared/runners.js +20 -0
  262. package/dist/shared/runners.js.map +1 -1
  263. package/dist/shared/ui-message.d.ts +4 -2
  264. package/dist/shared/ui-message.d.ts.map +1 -1
  265. package/dist/shared/ui-message.js +2 -1
  266. package/dist/shared/ui-message.js.map +1 -1
  267. package/docs/ui-message-clients.md +5 -1
  268. package/docs/ui-messages.md +3 -1
  269. package/docs/vectors-migration.md +91 -0
  270. package/package.json +14 -16
  271. package/skills/nocobase-app-plugin-agents/SKILL.md +1 -1
  272. package/skills/nocobase-app-plugin-agents/references/extension-points.md +0 -1
  273. package/skills/nocobase-app-plugin-agents/references/model-services.md +3 -3
  274. package/skills/nocobase-app-plugin-agents/references/online-agent.md +6 -0
  275. package/skills/nocobase-app-plugin-agents/references/setup-and-config.md +0 -4
  276. package/skills/nocobase-app-plugin-agents/references/verification-and-troubleshooting.md +0 -2
  277. package/dist/server/vectors/index.d.ts +0 -7
  278. package/dist/server/vectors/index.d.ts.map +0 -1
  279. package/dist/server/vectors/index.js +0 -6
  280. package/dist/server/vectors/index.js.map +0 -1
  281. package/dist/server/vectors/naming.d.ts +0 -6
  282. package/dist/server/vectors/naming.d.ts.map +0 -1
  283. package/dist/server/vectors/naming.js +0 -15
  284. package/dist/server/vectors/naming.js.map +0 -1
  285. package/dist/server/vectors/pgvector.d.ts +0 -26
  286. package/dist/server/vectors/pgvector.d.ts.map +0 -1
  287. package/dist/server/vectors/pgvector.js +0 -346
  288. package/dist/server/vectors/pgvector.js.map +0 -1
  289. package/dist/server/vectors/sqlite-vec.d.ts +0 -14
  290. package/dist/server/vectors/sqlite-vec.d.ts.map +0 -1
  291. package/dist/server/vectors/sqlite-vec.js +0 -286
  292. package/dist/server/vectors/sqlite-vec.js.map +0 -1
  293. package/dist/server/vectors/types.d.ts +0 -256
  294. package/dist/server/vectors/types.d.ts.map +0 -1
  295. package/dist/server/vectors/types.js +0 -2
  296. package/dist/server/vectors/types.js.map +0 -1
  297. package/dist/server/vectors/vectors.d.ts +0 -34
  298. package/dist/server/vectors/vectors.d.ts.map +0 -1
  299. package/dist/server/vectors/vectors.js +0 -866
  300. package/dist/server/vectors/vectors.js.map +0 -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,18 +25,83 @@ 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
- resumed = new Set();
35
- resuming = new Map();
36
- recoveryErrors = new Map();
104
+ recoveries = new Map();
37
105
  admissions = new Map();
38
106
  watches = new Map();
39
107
  messageVersions = new Map();
@@ -47,6 +115,8 @@ export class ChatController {
47
115
  disposed = false;
48
116
  generation = 0;
49
117
  detailVersions = new Map();
118
+ receiptVersions = new Map();
119
+ pendingReceipts = new Map();
50
120
  commandVersions = new Map();
51
121
  constructor(api) {
52
122
  this.api = api;
@@ -69,7 +139,22 @@ export class ChatController {
69
139
  const admission = this.admissions.get(id);
70
140
  if (!this.disposed && admission?.clientId === clientId)
71
141
  admission.gate.resolve();
72
- }, (options) => this.resendAccepted(id, options)),
142
+ }, (options) => this.resendAccepted(id, options), () => {
143
+ const active = this.lifetime(), recovery = this.recoveries.get(id);
144
+ return (available) => {
145
+ if (!active() ||
146
+ this.chats.get(id) !== chat ||
147
+ !recovery ||
148
+ this.recoveries.get(id) !== recovery)
149
+ return;
150
+ recovery.phase = available ? 'live' : 'history';
151
+ recovery.error = undefined;
152
+ // Reads started before reconnection must not replace its replay or revive old errors.
153
+ this.nextDetailVersion(id);
154
+ this.invalidateHistoryReads(id);
155
+ this.publish();
156
+ };
157
+ }),
73
158
  onFinish: () => {
74
159
  if (!this.disposed && this.chats.get(id) === chat)
75
160
  this.scheduleRefresh(id);
@@ -180,15 +265,18 @@ export class ChatController {
180
265
  this.publish();
181
266
  }
182
267
  historyLifetime(id) {
183
- const active = this.lifetime(), epoch = this.historyEpochs.get(id) ?? 0;
184
- return () => active() && (this.historyEpochs.get(id) ?? 0) === epoch;
268
+ const active = this.lifetime(), epoch = this.historyEpochs.get(id) ?? 0, command = this.commandVersions.get(id);
269
+ return () => active() &&
270
+ (this.historyEpochs.get(id) ?? 0) === epoch &&
271
+ this.commandVersions.get(id) === command;
185
272
  }
186
- invalidateHistoryReads(id) {
273
+ invalidateHistoryReads(id, discardDeferred = false) {
187
274
  this.historyEpochs.set(id, (this.historyEpochs.get(id) ?? 0) + 1);
188
275
  this.loads.delete(id);
189
276
  this.refreshAgain.delete(id);
190
277
  this.revisionQueue.delete(id);
191
- this.deferredReads.delete(id);
278
+ if (discardDeferred)
279
+ this.deferredReads.delete(id);
192
280
  for (const key of this.messageVersions.keys())
193
281
  if (key.startsWith(`${id}:`))
194
282
  this.messageVersions.delete(key);
@@ -221,8 +309,8 @@ export class ChatController {
221
309
  if (previous?.executionId === event.executionId ||
222
310
  (previous && previous.lastSeq > event.lastSeq))
223
311
  return false;
224
- this.invalidateHistoryReads(id);
225
- this.resuming.delete(id);
312
+ this.invalidateHistoryReads(id, true);
313
+ this.recoveries.delete(id);
226
314
  this.replacements.set(id, event);
227
315
  this.dispatch(id, { type: 'replaceHistory', ...event });
228
316
  this.historyMeta(id).loadedThrough = Math.max(this.historyMeta(id).loadedThrough, event.lastSeq);
@@ -391,37 +479,31 @@ export class ChatController {
391
479
  const initial = this.chat(id).messages.filter((m) => m.metadata?.seq !== undefined);
392
480
  const task = (async () => {
393
481
  const found = [];
394
- for (let offset = 0; offset < initial.length; offset += 100) {
395
- const page = await this.api.messages(id, {
396
- ids: initial
397
- .slice(offset, offset + 100)
398
- .map((m) => m.id)
399
- .join(','),
400
- });
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 });
401
497
  if (!active())
402
498
  return;
403
- found.push(...page.items);
499
+ found.push(...window.items);
404
500
  }
405
501
  const present = new Set(found.map((m) => m.id));
406
502
  const missing = new Set(initial.filter((m) => !present.has(m.id)).map((m) => m.id));
407
- let window;
408
- if (missing.size) {
409
- const oldest = Math.min(...initial.map((m) => m.metadata.seq));
410
- const seen = new Set();
411
- do {
412
- window = await this.api.messages(id, window?.nextCursor ? { pageToken: window.nextCursor } : {});
413
- if (!active())
414
- return;
415
- found.push(...window.items);
416
- if (!window.nextCursor ||
417
- !window.items.length ||
418
- window.items[0].metadata.seq <= oldest)
419
- break;
420
- if (seen.has(window.nextCursor))
421
- throw new Error('History pagination did not advance.');
422
- seen.add(window.nextCursor);
423
- } while (window.nextCursor);
424
- }
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;
425
507
  if (!active())
426
508
  return;
427
509
  const changed = found.find((m) => m.role === 'user' &&
@@ -452,6 +534,13 @@ export class ChatController {
452
534
  ? chat.messages.at(-1)?.id
453
535
  : undefined;
454
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);
455
544
  if (window)
456
545
  this.historyMeta(id).page = {
457
546
  ...this.historyMeta(id).page,
@@ -547,9 +636,29 @@ export class ChatController {
547
636
  const found = [];
548
637
  const active = this.historyLifetime(id);
549
638
  for (let offset = 0; offset < ids.length; offset += 100) {
550
- const page = await this.api.messages(id, {
551
- ids: ids.slice(offset, offset + 100).join(','),
552
- });
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
+ }
553
662
  if (!active())
554
663
  return [];
555
664
  found.push(...page.items);
@@ -566,6 +675,42 @@ export class ChatController {
566
675
  }
567
676
  return found;
568
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
+ }
569
714
  watch(id) {
570
715
  let watch = this.watches.get(id);
571
716
  if (!watch) {
@@ -575,11 +720,15 @@ export class ChatController {
575
720
  return;
576
721
  const detail = agentsQueryClient().getQueryData(chatKeys.conversation(id));
577
722
  const current = this.watches.get(id);
578
- 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;
579
728
  if (Date.now() - current.last < interval)
580
729
  return;
581
730
  current.last = Date.now();
582
- void this.refresh(id).catch(() => { });
731
+ void this.refresh(id, true, !busy, false).catch(() => { });
583
732
  }, 5000);
584
733
  watch = { refs: 0, timer, last: Date.now() };
585
734
  this.watches.set(id, watch);
@@ -594,12 +743,24 @@ export class ChatController {
594
743
  };
595
744
  }
596
745
  reconnect() {
597
- for (const id of this.watches.keys())
598
- 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)
599
751
  .then(() => this.resume(id))
600
- .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
+ }
601
759
  }
602
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;
603
764
  const key = `${id}:${messageId}`;
604
765
  if (((this.messageVersions.get(key) ?? -1) >= revision &&
605
766
  !this.deferredReads.get(id)?.has(messageId)) ||
@@ -617,9 +778,17 @@ export class ChatController {
617
778
  queue.task = (async () => {
618
779
  const attempted = new Set();
619
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;
620
786
  while (active() && queue.pending.size) {
621
- 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));
622
789
  queue.pending.clear();
790
+ if (!ids.length)
791
+ continue;
623
792
  ids.forEach((id) => attempted.add(id));
624
793
  await this.reread(id, ids);
625
794
  }
@@ -664,14 +833,16 @@ export class ChatController {
664
833
  async sendInput(id, input) {
665
834
  if (this.disposed)
666
835
  throw new CancelledError({ silent: true });
667
- const chat = this.chat(id);
836
+ let chat = this.chat(id);
668
837
  if (chat.status === 'submitted' || chat.status === 'streaming')
669
838
  throw new Error('This conversation is already receiving a response.');
670
839
  if (this.resendRequests.has(id))
671
840
  throw new Error('This conversation is busy.');
672
841
  const admitted = deferred();
673
842
  this.admissions.set(id, { clientId: input.clientId, gate: admitted });
674
- this.recoveryErrors.delete(id);
843
+ this.recoveries.delete(id);
844
+ // A new turn owns its callbacks, including when the previous transport finishes late.
845
+ chat = this.renewChat(id, chat.messages);
675
846
  const active = this.lifetime();
676
847
  const sending = chat.sendMessage({
677
848
  id: input.clientId,
@@ -697,6 +868,7 @@ export class ChatController {
697
868
  }, {
698
869
  body: {
699
870
  ...(input.agentId ? { agentId: input.agentId } : {}),
871
+ ...(input.effort !== undefined ? { effort: input.effort } : {}),
700
872
  ...(input.context ? { context: input.context } : {}),
701
873
  ...(input.attachmentIds?.length
702
874
  ? { attachmentIds: input.attachmentIds }
@@ -727,42 +899,98 @@ export class ChatController {
727
899
  }
728
900
  state(id) {
729
901
  const chat = this.chats.get(id);
902
+ const recovery = this.recoveries.get(id);
903
+ const recovering = recovery?.phase === 'recovering';
904
+ const error = recovering ? undefined : (chat?.error ?? recovery?.error);
730
905
  return chat
731
906
  ? {
732
907
  status: chat.status,
733
- error: chat.error ?? this.recoveryErrors.get(id),
908
+ error,
909
+ recovery: recovering
910
+ ? 'recovering'
911
+ : error
912
+ ? 'interrupted'
913
+ : recovery?.phase === 'history'
914
+ ? 'history'
915
+ : undefined,
734
916
  }
735
917
  : undefined;
736
918
  }
919
+ recoveryKey(id, detail) {
920
+ return (detail.run?.id ??
921
+ detail.execution?.messageId ??
922
+ (detail.execution?.status === 'running'
923
+ ? this.recoveries.get(id)?.key
924
+ : undefined) ??
925
+ [...this.chat(id).messages].reverse().find((m) => m.role === 'user')
926
+ ?.id ??
927
+ '');
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
+ }
737
942
  receive(id, known) {
738
- if (this.disposed || this.resumed.has(id))
943
+ if (this.disposed)
739
944
  return;
740
945
  const detail = known ??
741
946
  agentsQueryClient().getQueryData(chatKeys.conversation(id));
742
- if (!detail?.execution)
947
+ if (!detail ||
948
+ this.recoveries.get(id)?.key === this.recoveryKey(id, detail))
743
949
  return;
744
- this.resumed.add(id);
745
- if (detail?.execution?.status === 'idle' ||
746
- detail?.execution?.waitingApproval)
747
- return;
748
- void this.resume(id);
950
+ void this.resume(id, detail);
749
951
  }
750
- async resume(id) {
952
+ async resume(id, known) {
751
953
  if (this.disposed)
752
954
  return;
753
- const pending = this.resuming.get(id);
754
- if (pending)
755
- return pending;
955
+ const detail = known ??
956
+ agentsQueryClient().getQueryData(chatKeys.conversation(id));
957
+ if (!detail)
958
+ return;
959
+ if (!chatIsRunning(detail) || detail.execution?.waitingApproval) {
960
+ const chat = this.chats.get(id);
961
+ if (chat?.status !== 'submitted' &&
962
+ chat?.status !== 'streaming' &&
963
+ (!this.history(id).state.loaded ||
964
+ this.history(id).loadedThrough < (detail.lastSeq ?? 0)))
965
+ await this.loadHistory(id);
966
+ return;
967
+ }
968
+ const key = this.recoveryKey(id, detail);
969
+ const pending = this.recoveries.get(id);
756
970
  let chat = this.chat(id);
757
- if (chat.status === 'submitted' || chat.status === 'streaming')
971
+ if (chat.status === 'submitted' || chat.status === 'streaming') {
972
+ if (!pending || pending.key !== key)
973
+ this.recoveries.set(id, { key, phase: 'live' });
758
974
  return;
975
+ }
976
+ if (pending?.key === key) {
977
+ if (pending.task)
978
+ return pending.task;
979
+ if (pending.phase === 'history')
980
+ return;
981
+ }
759
982
  const active = this.lifetime();
760
983
  const replacement = this.replacements.get(id);
761
- const canResume = () => active() &&
984
+ const command = this.commandVersions.get(id);
985
+ const recovery = { key, phase: 'recovering' };
986
+ this.recoveries.set(id, recovery);
987
+ this.publish();
988
+ const current = () => active() &&
989
+ this.commandVersions.get(id) === command &&
990
+ this.recoveries.get(id) === recovery &&
762
991
  this.replacements.get(id) === replacement &&
763
- this.chats.get(id) === chat &&
764
- chat.status !== 'submitted' &&
765
- chat.status !== 'streaming';
992
+ this.chats.get(id) === chat;
993
+ const canResume = () => current() && chat.status !== 'submitted' && chat.status !== 'streaming';
766
994
  const task = (async () => {
767
995
  let interrupted;
768
996
  try {
@@ -770,6 +998,8 @@ export class ChatController {
770
998
  // A new input can begin while the recovery snapshot is still being read.
771
999
  if (!canResume())
772
1000
  return;
1001
+ if (this.history(id).error)
1002
+ throw this.history(id).error;
773
1003
  if (chat.error) {
774
1004
  const previous = chat;
775
1005
  interrupted = [...previous.messages]
@@ -790,36 +1020,63 @@ export class ChatController {
790
1020
  if (!active())
791
1021
  return;
792
1022
  }
793
- this.recoveryErrors.delete(id);
794
1023
  chat.clearError();
795
1024
  const response = [...chat.messages]
796
1025
  .reverse()
797
1026
  .find((message) => message.role === 'assistant');
798
- if (response)
1027
+ if (response &&
1028
+ (response === interrupted ||
1029
+ !key ||
1030
+ response.metadata?.executionId === key ||
1031
+ response.id === detail.execution?.messageId)) {
1032
+ interrupted ??= response;
799
1033
  chat.messages = mergeUIMessages(chat.messages, [], response.id);
1034
+ }
800
1035
  await chat.resumeStream();
801
- if (active())
802
- await this.refresh(id);
1036
+ if (current()) {
1037
+ if (chat.error) {
1038
+ recovery.phase = 'interrupted';
1039
+ recovery.error = chat.error;
1040
+ }
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);
1048
+ }
803
1049
  }
804
1050
  catch (error) {
805
- if (canResume())
806
- this.recoveryErrors.set(id, error instanceof Error
807
- ? error
808
- : new Error('Stream recovery failed.'));
1051
+ if (current()) {
1052
+ if (chat.error) {
1053
+ recovery.phase = 'interrupted';
1054
+ recovery.error = chat.error;
1055
+ }
1056
+ else {
1057
+ // A failed history read is not evidence that the transport failed.
1058
+ this.historyMeta(id).error = error;
1059
+ if (recovery.phase === 'recovering')
1060
+ recovery.phase = 'live';
1061
+ }
1062
+ }
809
1063
  }
810
1064
  finally {
811
- if (active()) {
1065
+ if (current()) {
812
1066
  const display = interrupted;
813
1067
  if (display &&
814
1068
  canResume() &&
815
1069
  !chat.messages.some((message) => message.id === display.id))
816
1070
  chat.messages = mergeUIMessages(chat.messages, [display]);
817
- this.resuming.delete(id);
1071
+ recovery.task = undefined;
1072
+ if (recovery.phase === 'recovering' &&
1073
+ (chat.status === 'submitted' || chat.status === 'streaming'))
1074
+ recovery.phase = 'live';
818
1075
  this.publish();
819
1076
  }
820
1077
  }
821
1078
  })();
822
- this.resuming.set(id, task);
1079
+ recovery.task = task;
823
1080
  return task;
824
1081
  }
825
1082
  async approve(id, input) {
@@ -864,21 +1121,127 @@ export class ChatController {
864
1121
  if (active())
865
1122
  await this.refresh(id);
866
1123
  }
867
- 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) {
868
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) {
1169
+ if (this.disposed)
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
+ }
1181
+ const lifetime = this.lifetime(), commandVersion = this.commandVersions.get(id), active = () => lifetime() && this.commandVersions.get(id) === commandVersion, ordered = this.detailRequest(id);
1182
+ const submittedOwner = this.chat(id);
1183
+ const submittedInput = submittedOwner.status === 'submitted' && !this.admissions.has(id)
1184
+ ? [...submittedOwner.messages]
1185
+ .reverse()
1186
+ .find((message) => message.role === 'user' && message.metadata?.seq !== undefined)
1187
+ : undefined;
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
+ }
869
1223
  return;
870
- const active = this.lifetime(), ordered = this.detailRequest(id);
871
- const detail = await this.api.conversation(id);
1224
+ }
872
1225
  if (!active())
873
1226
  return;
874
- if (ordered())
1227
+ const reconciled = fresh && !snapshot && reconcileHistory && ordered();
1228
+ if (reconciled)
875
1229
  await this.reconcile(id, detail);
876
1230
  if (!active())
877
1231
  return;
878
1232
  // A newer detail query must not discard this refresh's message catch-up.
879
1233
  // loadHistory coalesces overlapping reads independently of detail ordering.
880
- await this.loadHistory(id, false, true);
881
- if (!active() || !ordered())
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
+ }
1244
+ if (!active())
882
1245
  return;
883
1246
  const history = this.history(id);
884
1247
  if (history.error)
@@ -892,25 +1255,49 @@ export class ChatController {
892
1255
  const revisable = [
893
1256
  ...new Set([
894
1257
  ...(this.deferredReads.get(id) ?? []),
895
- ...(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))
896
1263
  ? [detail.execution.messageId]
897
- : latest
1264
+ : !snapshot &&
1265
+ !reconciled &&
1266
+ this.history(id).loadedThrough === beforeThrough &&
1267
+ latest
898
1268
  ? [latest.id]
899
1269
  : []),
900
1270
  ]),
901
1271
  ];
902
- 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];
903
1275
  if (!active() || !ordered())
904
1276
  return;
905
1277
  const owner = this.chat(id), receiving = owner.messages.at(-1);
906
- if (owner.status === 'streaming' &&
907
- receiving?.role === 'assistant' &&
908
- (detail.execution?.status === 'idle' ||
909
- detail.execution?.waitingApproval) &&
910
- saved.some((message) => message.id === receiving.id &&
1278
+ const lastUser = [...owner.messages]
1279
+ .reverse()
1280
+ .find((message) => message.role === 'user');
1281
+ const executionId = submittedInput?.metadata?.executionId ?? this.recoveries.get(id)?.key;
1282
+ const submittedTerminal = owner === submittedOwner &&
1283
+ owner.status === 'submitted' &&
1284
+ submittedInput &&
1285
+ lastUser?.id === submittedInput.id &&
1286
+ detail.execution?.status === 'idle' &&
1287
+ [...saved, ...owner.messages].some((message) => message.role !== 'user' &&
911
1288
  message.metadata?.seq !== undefined &&
912
- message.metadata?.executionId === receiving.metadata?.executionId &&
913
- message.metadata.revision >= (detail.execution?.revision ?? 0))) {
1289
+ message.metadata.seq > submittedInput.metadata.seq &&
1290
+ ((executionId && message.metadata.executionId === executionId) ||
1291
+ message.id === detail.execution?.messageId));
1292
+ if (submittedTerminal ||
1293
+ (owner.status === 'streaming' &&
1294
+ receiving?.role === 'assistant' &&
1295
+ (detail.execution?.status === 'idle' ||
1296
+ detail.execution?.waitingApproval) &&
1297
+ saved.some((message) => message.id === receiving.id &&
1298
+ message.metadata?.seq !== undefined &&
1299
+ message.metadata?.executionId === receiving.metadata?.executionId &&
1300
+ message.metadata.revision >= (detail.execution?.revision ?? 0)))) {
914
1301
  // Durable completion can release a lost subscriber; this never calls business Stop.
915
1302
  await owner.stop();
916
1303
  if (!active() || !ordered())
@@ -919,23 +1306,24 @@ export class ChatController {
919
1306
  }
920
1307
  if (!active() || !ordered())
921
1308
  return;
922
- this.remember(detail);
1309
+ if (!snapshot)
1310
+ this.remember(detail, false);
923
1311
  const chat = this.chat(id);
924
1312
  if (detail.execution?.status === 'idle') {
925
- this.recoveryErrors.delete(id);
926
- if (chat.error &&
927
- saved.some((message) => message.id === chat.messages.at(-1)?.id &&
928
- message.metadata?.seq !== undefined))
1313
+ this.recoveries.delete(id);
1314
+ if (chat.error)
929
1315
  this.renewChat(id, chat.messages);
930
1316
  }
931
- else if (detail.execution?.status === 'running' &&
932
- !detail.execution.waitingApproval &&
933
- chat.status !== 'submitted' &&
934
- chat.status !== 'streaming')
935
- this.recoveryErrors.set(id, new Error('Execution remains active; reconcile saved history or resume its stream.'));
936
1317
  this.publish();
937
1318
  }
938
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
+ }
939
1327
  if (this.completionReads.has(id))
940
1328
  return;
941
1329
  this.completionReads.add(id);
@@ -945,14 +1333,18 @@ export class ChatController {
945
1333
  void this.refresh(id).catch(() => { });
946
1334
  });
947
1335
  }
948
- observeDetail(detail) {
1336
+ observeDetail(detail, refresh = true, synchronizeLists = true) {
1337
+ if (synchronizeLists)
1338
+ this.syncLists(detail);
949
1339
  if (detail.execution?.status === 'running')
950
1340
  this.receive(detail.id, detail);
951
1341
  if (!chatIsRunning(detail)) {
952
- if (this.chats.get(detail.id)?.status === 'streaming')
953
- this.scheduleRefresh(detail.id);
1342
+ if (refresh &&
1343
+ (this.chats.get(detail.id)?.status === 'streaming' ||
1344
+ this.chats.get(detail.id)?.status === 'submitted'))
1345
+ void this.refreshOnce(detail.id, false, false, detail).catch(() => { });
954
1346
  this.stopRequests.delete(detail.id);
955
- this.recoveryErrors.delete(detail.id);
1347
+ this.recoveries.delete(detail.id);
956
1348
  this.publish();
957
1349
  }
958
1350
  }
@@ -987,16 +1379,112 @@ export class ChatController {
987
1379
  const active = this.lifetime(), version = this.nextDetailVersion(id);
988
1380
  return () => active() && this.detailVersions.get(id) === version;
989
1381
  }
990
- remember(detail) {
1382
+ remember(detail, refresh = true) {
991
1383
  this.nextDetailVersion(detail.id);
992
- this.observeDetail(detail);
993
- agentsQueryClient().setQueryData(chatKeys.conversation(detail.id), detail);
994
- 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;
1424
+ }
1425
+ async markRead(id) {
1426
+ const client = agentsQueryClient();
1427
+ const key = chatKeys.conversation(id);
1428
+ const before = client.getQueryData(key);
1429
+ const active = this.lifetime(), commandVersion = this.commandVersions.get(id), receiptVersion = (this.receiptVersions.get(id) ?? 0) + 1;
1430
+ // Detail reads must not suppress the read transition needed by a viewed conversation.
1431
+ this.receiptVersions.set(id, receiptVersion);
1432
+ const pending = { seenSeq: -1 };
1433
+ this.pendingReceipts.set(id, pending);
1434
+ this.nextDetailVersion(id);
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
+ }
1473
+ }
995
1474
  }
996
1475
  async command(id, request, detail) {
997
1476
  const active = this.lifetime(), version = (this.commandVersions.get(id) ?? 0) + 1;
998
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);
999
1481
  this.nextDetailVersion(id);
1482
+ this.invalidateHistoryReads(id);
1483
+ const recovery = this.recoveries.get(id);
1484
+ if (recovery?.phase === 'history')
1485
+ this.recoveries.set(id, { key: recovery.key, phase: 'history' });
1486
+ else
1487
+ this.recoveries.delete(id);
1000
1488
  await agentsQueryClient().cancelQueries({
1001
1489
  queryKey: chatKeys.conversation(id),
1002
1490
  exact: true,
@@ -1022,6 +1510,8 @@ export class ChatController {
1022
1510
  this.disposed = false;
1023
1511
  }
1024
1512
  invalidate() {
1513
+ this.invalidations?.stop();
1514
+ this.invalidations = undefined;
1025
1515
  if (this.disposed)
1026
1516
  return;
1027
1517
  this.disposed = true;
@@ -1036,6 +1526,9 @@ export class ChatController {
1036
1526
  this.historyEpochs.clear();
1037
1527
  this.replacements.clear();
1038
1528
  this.reconciles.clear();
1529
+ this.streamFollowers.clear();
1530
+ this.detailReads.clear();
1531
+ this.refreshes.clear();
1039
1532
  for (const watch of this.watches.values())
1040
1533
  clearInterval(watch.timer);
1041
1534
  this.watches.clear();
@@ -1045,14 +1538,19 @@ export class ChatController {
1045
1538
  this.completionReads.clear();
1046
1539
  this.admissions.clear();
1047
1540
  this.loads.clear();
1541
+ this.messageReads.clear();
1542
+ this.catchups.clear();
1543
+ this.reconnects.clear();
1048
1544
  this.refreshAgain.clear();
1049
1545
  this.commandVersions.clear();
1546
+ this.receiptVersions.clear();
1547
+ this.pendingReceipts.clear();
1050
1548
  this.stopRequests.clear();
1051
1549
  this.approvalRequests.clear();
1052
- this.resuming.clear();
1053
- this.resumed.clear();
1550
+ this.recoveries.clear();
1054
1551
  this.submissions.invalidate();
1055
- agentsQueryClient().removeQueries({ queryKey: chatKeys.all });
1552
+ // All plugin snapshots are viewer-scoped, including fresh agent options.
1553
+ agentsQueryClient().removeQueries({ queryKey: agentsKeys.all });
1056
1554
  }
1057
1555
  dispose() {
1058
1556
  this.invalidate();
@@ -1071,10 +1569,7 @@ export function ChatClientProvider({ children, }) {
1071
1569
  controller.activate();
1072
1570
  const lifetimes = lifetimesRef.current;
1073
1571
  lifetimes.set(controller, (lifetimes.get(controller) ?? 0) + 1);
1074
- const reconnect = () => controller.reconnect();
1075
- window.addEventListener('online', reconnect);
1076
1572
  return () => {
1077
- window.removeEventListener('online', reconnect);
1078
1573
  controller.invalidate();
1079
1574
  lifetimes.set(controller, (lifetimes.get(controller) ?? 1) - 1);
1080
1575
  queueMicrotask(() => {
@@ -1095,12 +1590,13 @@ export function useChatController() {
1095
1590
  }
1096
1591
  export function useChatStream(id) {
1097
1592
  const controller = useChatController();
1593
+ const ready = useRealtimeReadiness([CONVERSATIONS_TOPIC]);
1098
1594
  useSDKChat({ chat: controller.chat(id ?? 'draft') });
1099
1595
  useSyncExternalStore(controller.subscribe, controller.snapshot, controller.snapshot);
1100
1596
  useEffect(() => {
1101
- if (id)
1102
- controller.receive(id);
1103
- }, [controller, id]);
1597
+ if (ready && id)
1598
+ return controller.followStream(id);
1599
+ }, [controller, id, ready]);
1104
1600
  return id ? controller.state(id) : undefined;
1105
1601
  }
1106
1602
  export function useChatStreamState(id) {