@dxos/plugin-assistant 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/PLUGIN.mdl +25 -0
  2. package/dist/lib/AssistantCapabilities.mjs +8 -1
  3. package/dist/lib/AssistantCapabilities.mjs.map +1 -1
  4. package/dist/lib/Settings.mjs +4 -0
  5. package/dist/lib/Settings.mjs.map +1 -1
  6. package/dist/lib/TurnReviewSkill.mjs +55 -0
  7. package/dist/lib/TurnReviewSkill.mjs.map +1 -0
  8. package/dist/lib/chunk-AgentArticle.mjs +1 -1
  9. package/dist/lib/chunk-AssistantSettings.mjs +1 -1
  10. package/dist/lib/chunk-AssistantSettings.mjs.map +1 -1
  11. package/dist/lib/chunk-Chat.mjs +322 -174
  12. package/dist/lib/chunk-Chat.mjs.map +1 -1
  13. package/dist/lib/chunk-ChatArticle.mjs +14 -7
  14. package/dist/lib/chunk-ChatArticle.mjs.map +1 -1
  15. package/dist/lib/chunk-ChatDialog.mjs +5 -4
  16. package/dist/lib/chunk-ChatDialog.mjs.map +1 -1
  17. package/dist/lib/chunk-IntegrationPrompt.mjs +1 -1
  18. package/dist/lib/chunk-PLUGIN.mjs +1 -1
  19. package/dist/lib/chunk-PluginUrlPrompt.mjs +1 -1
  20. package/dist/lib/chunk-QuestionSurface.mjs +1 -1
  21. package/dist/lib/chunk-SpaceHomePrompt.mjs +116 -60
  22. package/dist/lib/chunk-SpaceHomePrompt.mjs.map +1 -1
  23. package/dist/lib/chunk-TriggerStatus.mjs +1 -1
  24. package/dist/lib/chunk-agent-service.mjs +3 -1
  25. package/dist/lib/chunk-agent-service.mjs.map +1 -1
  26. package/dist/lib/chunk-analyze-turn.mjs +220 -0
  27. package/dist/lib/chunk-analyze-turn.mjs.map +1 -0
  28. package/dist/lib/{chunk-processor.mjs → chunk-chat-model.mjs} +95 -43
  29. package/dist/lib/chunk-chat-model.mjs.map +1 -0
  30. package/dist/lib/chunk-context.mjs.map +1 -1
  31. package/dist/lib/chunk-create-chat.mjs +5 -19
  32. package/dist/lib/chunk-create-chat.mjs.map +1 -1
  33. package/dist/lib/chunk-create-object.mjs +1 -1
  34. package/dist/lib/chunk-default-skills.mjs +28 -0
  35. package/dist/lib/chunk-default-skills.mjs.map +1 -0
  36. package/dist/lib/chunk-generate-home-suggestions.mjs +8 -6
  37. package/dist/lib/chunk-generate-home-suggestions.mjs.map +1 -1
  38. package/dist/lib/chunk-presets.mjs +1 -1
  39. package/dist/lib/chunk-presets.mjs.map +1 -1
  40. package/dist/lib/chunk-question-resumer.mjs +1 -1
  41. package/dist/lib/chunk-react-surface.mjs +1 -1
  42. package/dist/lib/chunk-run-prompt-in-chat.mjs +2 -1
  43. package/dist/lib/chunk-run-prompt-in-chat.mjs.map +1 -1
  44. package/dist/lib/chunk-run-prompt-in-new-chat.mjs +1 -1
  45. package/dist/lib/chunk-schema-defs.mjs +2 -2
  46. package/dist/lib/chunk-skill-definition.mjs +3 -2
  47. package/dist/lib/chunk-skill-definition.mjs.map +1 -1
  48. package/dist/lib/components.mjs +13 -13
  49. package/dist/lib/components.mjs.map +1 -1
  50. package/dist/lib/hooks.mjs +40 -31
  51. package/dist/lib/hooks.mjs.map +1 -1
  52. package/dist/lib/operations.mjs +3 -1
  53. package/dist/lib/operations.mjs.map +1 -1
  54. package/dist/lib/skills.mjs +2 -1
  55. package/dist/lib/translations.mjs +4 -2
  56. package/dist/lib/translations.mjs.map +1 -1
  57. package/dist/types/src/Chat.d.ts +1 -1
  58. package/dist/types/src/capabilities/agent-service.d.ts.map +1 -1
  59. package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -1
  60. package/dist/types/src/{processor/processor.d.ts → chat-model/chat-model.d.ts} +24 -22
  61. package/dist/types/src/chat-model/chat-model.d.ts.map +1 -0
  62. package/dist/types/src/chat-model/chat-model.edge.test.d.ts +2 -0
  63. package/dist/types/src/chat-model/chat-model.edge.test.d.ts.map +1 -0
  64. package/dist/types/src/chat-model/chat-model.node.test.d.ts +2 -0
  65. package/dist/types/src/chat-model/chat-model.node.test.d.ts.map +1 -0
  66. package/dist/types/src/chat-model/chat-model.scripted.node.test.d.ts +2 -0
  67. package/dist/types/src/chat-model/chat-model.scripted.node.test.d.ts.map +1 -0
  68. package/dist/types/src/{processor → chat-model}/index.d.ts +1 -1
  69. package/dist/types/src/chat-model/index.d.ts.map +1 -0
  70. package/dist/types/src/chat-model/outbox.d.ts.map +1 -0
  71. package/dist/types/src/chat-model/outbox.test.d.ts.map +1 -0
  72. package/dist/types/src/chat-model/presets.d.ts.map +1 -0
  73. package/dist/types/src/chat-model/presets.test.d.ts.map +1 -0
  74. package/dist/types/src/{processor → chat-model}/prompt.d.ts +2 -2
  75. package/dist/types/src/chat-model/prompt.d.ts.map +1 -0
  76. package/dist/types/src/chat-model/prompt.test.d.ts.map +1 -0
  77. package/dist/types/src/chat-model/streaming.node.test.d.ts.map +1 -0
  78. package/dist/types/src/chat-model/testing/chat-model-recorder.d.ts +58 -0
  79. package/dist/types/src/chat-model/testing/chat-model-recorder.d.ts.map +1 -0
  80. package/dist/types/src/chat-model/testing/harness.d.ts +22 -0
  81. package/dist/types/src/chat-model/testing/harness.d.ts.map +1 -0
  82. package/dist/types/src/chat-model/thread.d.ts.map +1 -0
  83. package/dist/types/src/chat-model/thread.test.d.ts.map +1 -0
  84. package/dist/types/src/components/Chat/Chat.d.ts +6 -6
  85. package/dist/types/src/components/Chat/Chat.d.ts.map +1 -1
  86. package/dist/types/src/components/Chat/context.d.ts +5 -3
  87. package/dist/types/src/components/Chat/context.d.ts.map +1 -1
  88. package/dist/types/src/components/ChatPrompt/ChatActions.d.ts +1 -1
  89. package/dist/types/src/components/ChatPrompt/ChatActions.d.ts.map +1 -1
  90. package/dist/types/src/components/ChatPrompt/ChatMcpErrors.d.ts +3 -3
  91. package/dist/types/src/components/ChatPrompt/ChatMcpErrors.d.ts.map +1 -1
  92. package/dist/types/src/components/ChatPrompt/ChatOptions.d.ts +3 -1
  93. package/dist/types/src/components/ChatPrompt/ChatOptions.d.ts.map +1 -1
  94. package/dist/types/src/components/ChatPrompt/ChatOptions.stories.d.ts +4 -3
  95. package/dist/types/src/components/ChatPrompt/ChatOptions.stories.d.ts.map +1 -1
  96. package/dist/types/src/components/ChatPrompt/ChatPrompt.d.ts +15 -5
  97. package/dist/types/src/components/ChatPrompt/ChatPrompt.d.ts.map +1 -1
  98. package/dist/types/src/components/ChatPrompt/ChatPrompt.stories.d.ts +1 -1
  99. package/dist/types/src/components/ChatPrompt/ChatStatus.d.ts +1 -1
  100. package/dist/types/src/components/ChatPrompt/ChatStatus.d.ts.map +1 -1
  101. package/dist/types/src/components/ConnectorAuthMenu/ConnectorAuthMenu.stories.d.ts +9 -0
  102. package/dist/types/src/components/ConnectorAuthMenu/ConnectorAuthMenu.stories.d.ts.map +1 -1
  103. package/dist/types/src/components/Toolbox/Toolbox.d.ts +3 -3
  104. package/dist/types/src/components/Toolbox/Toolbox.d.ts.map +1 -1
  105. package/dist/types/src/containers/ChatArticle/ChatArticle.d.ts.map +1 -1
  106. package/dist/types/src/containers/ChatArticle/ChatArticle.stories.d.ts +1 -1
  107. package/dist/types/src/containers/SpaceHomePrompt/SpaceHomePrompt.d.ts.map +1 -1
  108. package/dist/types/src/hooks/index.d.ts +2 -2
  109. package/dist/types/src/hooks/index.d.ts.map +1 -1
  110. package/dist/types/src/hooks/{useChatProcessor.d.ts → useChatModel.d.ts} +6 -6
  111. package/dist/types/src/hooks/useChatModel.d.ts.map +1 -0
  112. package/dist/types/src/hooks/useDebug.d.ts +3 -3
  113. package/dist/types/src/hooks/useDebug.d.ts.map +1 -1
  114. package/dist/types/src/hooks/useHomeSuggestions.d.ts +3 -3
  115. package/dist/types/src/hooks/useHomeSuggestions.d.ts.map +1 -1
  116. package/dist/types/src/hooks/usePresets.d.ts +1 -1
  117. package/dist/types/src/hooks/usePresets.d.ts.map +1 -1
  118. package/dist/types/src/hooks/useSelectionContext.d.ts +3 -3
  119. package/dist/types/src/hooks/useSelectionContext.d.ts.map +1 -1
  120. package/dist/types/src/operations/analyze-turn.d.ts +5 -0
  121. package/dist/types/src/operations/analyze-turn.d.ts.map +1 -0
  122. package/dist/types/src/operations/analyze-turn.test.d.ts +2 -0
  123. package/dist/types/src/operations/analyze-turn.test.d.ts.map +1 -0
  124. package/dist/types/src/operations/create-chat.d.ts.map +1 -1
  125. package/dist/types/src/operations/generate-home-suggestions.d.ts.map +1 -1
  126. package/dist/types/src/operations/index.d.ts.map +1 -1
  127. package/dist/types/src/operations/run-prompt-in-chat.d.ts.map +1 -1
  128. package/dist/types/src/review/turn-review.d.ts +58 -0
  129. package/dist/types/src/review/turn-review.d.ts.map +1 -0
  130. package/dist/types/src/review/turn-review.test.d.ts +2 -0
  131. package/dist/types/src/review/turn-review.test.d.ts.map +1 -0
  132. package/dist/types/src/skills/index.d.ts +1 -0
  133. package/dist/types/src/skills/index.d.ts.map +1 -1
  134. package/dist/types/src/skills/turn-review/TurnReviewSkill.d.ts +21 -0
  135. package/dist/types/src/skills/turn-review/TurnReviewSkill.d.ts.map +1 -0
  136. package/dist/types/src/skills/turn-review/index.d.ts +2 -0
  137. package/dist/types/src/skills/turn-review/index.d.ts.map +1 -0
  138. package/dist/types/src/translations.d.ts.map +1 -1
  139. package/dist/types/src/types/Assistant.d.ts +1 -0
  140. package/dist/types/src/types/Assistant.d.ts.map +1 -1
  141. package/dist/types/src/types/AssistantCapabilities.d.ts +7 -1
  142. package/dist/types/src/types/AssistantCapabilities.d.ts.map +1 -1
  143. package/dist/types/src/types/Settings.d.ts +1 -0
  144. package/dist/types/src/types/Settings.d.ts.map +1 -1
  145. package/dist/types/src/util/agent-options.d.ts +27 -0
  146. package/dist/types/src/util/agent-options.d.ts.map +1 -0
  147. package/dist/types/src/util/agent-options.test.d.ts +2 -0
  148. package/dist/types/src/util/agent-options.test.d.ts.map +1 -0
  149. package/dist/types/src/util/default-skills.d.ts +10 -0
  150. package/dist/types/src/util/default-skills.d.ts.map +1 -0
  151. package/dist/types/tsconfig.tsbuildinfo +1 -1
  152. package/package.json +89 -80
  153. package/src/capabilities/agent-service.ts +2 -1
  154. package/src/capabilities/skill-definition.ts +2 -1
  155. package/src/chat-model/chat-model.edge.test.ts +301 -0
  156. package/src/{processor/processor.node.test.ts → chat-model/chat-model.node.test.ts} +19 -9
  157. package/src/chat-model/chat-model.scripted.node.test.ts +441 -0
  158. package/src/{processor/processor.ts → chat-model/chat-model.ts} +140 -45
  159. package/src/{processor → chat-model}/index.ts +1 -1
  160. package/src/{processor → chat-model}/prompt.ts +2 -2
  161. package/src/{processor → chat-model}/streaming.node.test.ts +108 -116
  162. package/src/chat-model/testing/chat-model-recorder.ts +249 -0
  163. package/src/chat-model/testing/harness.ts +81 -0
  164. package/src/{processor → chat-model}/thread.test.ts +1 -1
  165. package/src/components/Chat/Chat.tsx +44 -38
  166. package/src/components/Chat/Error.stories.tsx +1 -1
  167. package/src/components/Chat/context.ts +5 -3
  168. package/src/components/ChatPrompt/ChatActions.tsx +1 -1
  169. package/src/components/ChatPrompt/ChatMcpErrors.tsx +6 -6
  170. package/src/components/ChatPrompt/ChatOptions.stories.tsx +37 -3
  171. package/src/components/ChatPrompt/ChatOptions.tsx +118 -10
  172. package/src/components/ChatPrompt/ChatPrompt.stories.tsx +10 -10
  173. package/src/components/ChatPrompt/ChatPrompt.tsx +44 -27
  174. package/src/components/ChatPrompt/ChatStatus.tsx +8 -7
  175. package/src/components/Toolbox/Toolbox.tsx +12 -13
  176. package/src/containers/AssistantSettings/AssistantSettings.tsx +1 -1
  177. package/src/containers/ChatArticle/ChatArticle.stories.tsx +1 -1
  178. package/src/containers/ChatArticle/ChatArticle.tsx +16 -6
  179. package/src/containers/ChatDialog/ChatDialog.tsx +4 -4
  180. package/src/containers/SpaceHomePrompt/SpaceHomePrompt.tsx +110 -57
  181. package/src/hooks/index.ts +2 -2
  182. package/src/hooks/{useChatProcessor.ts → useChatModel.ts} +16 -14
  183. package/src/hooks/useDebug.ts +8 -8
  184. package/src/hooks/useHomeSuggestions.ts +14 -9
  185. package/src/hooks/usePresets.ts +2 -2
  186. package/src/hooks/useProcessEphemeralStatus.ts +1 -1
  187. package/src/hooks/useSelectionContext.ts +3 -5
  188. package/src/operations/analyze-turn.test.ts +194 -0
  189. package/src/operations/analyze-turn.ts +96 -0
  190. package/src/operations/create-chat.ts +4 -36
  191. package/src/operations/generate-home-suggestions.test.ts +1 -2
  192. package/src/operations/generate-home-suggestions.ts +17 -9
  193. package/src/operations/index.ts +2 -0
  194. package/src/operations/run-prompt-in-chat.ts +3 -2
  195. package/src/review/turn-review.test.ts +92 -0
  196. package/src/review/turn-review.ts +213 -0
  197. package/src/skills/index.ts +1 -0
  198. package/src/skills/turn-review/TurnReviewSkill.ts +46 -0
  199. package/src/skills/turn-review/index.ts +5 -0
  200. package/src/translations.ts +2 -0
  201. package/src/types/AssistantCapabilities.ts +8 -1
  202. package/src/types/Settings.ts +7 -0
  203. package/src/util/agent-options.test.ts +54 -0
  204. package/src/util/agent-options.ts +60 -0
  205. package/src/util/default-skills.ts +35 -0
  206. package/dist/lib/chunk-processor.mjs.map +0 -1
  207. package/dist/types/src/hooks/useChatProcessor.d.ts.map +0 -1
  208. package/dist/types/src/processor/index.d.ts.map +0 -1
  209. package/dist/types/src/processor/outbox.d.ts.map +0 -1
  210. package/dist/types/src/processor/outbox.test.d.ts.map +0 -1
  211. package/dist/types/src/processor/presets.d.ts.map +0 -1
  212. package/dist/types/src/processor/presets.test.d.ts.map +0 -1
  213. package/dist/types/src/processor/processor.d.ts.map +0 -1
  214. package/dist/types/src/processor/processor.node.test.d.ts +0 -2
  215. package/dist/types/src/processor/processor.node.test.d.ts.map +0 -1
  216. package/dist/types/src/processor/prompt.d.ts.map +0 -1
  217. package/dist/types/src/processor/prompt.test.d.ts.map +0 -1
  218. package/dist/types/src/processor/streaming.node.test.d.ts.map +0 -1
  219. package/dist/types/src/processor/thread.d.ts.map +0 -1
  220. package/dist/types/src/processor/thread.test.d.ts.map +0 -1
  221. /package/dist/types/src/{processor → chat-model}/outbox.d.ts +0 -0
  222. /package/dist/types/src/{processor → chat-model}/outbox.test.d.ts +0 -0
  223. /package/dist/types/src/{processor → chat-model}/presets.d.ts +0 -0
  224. /package/dist/types/src/{processor → chat-model}/presets.test.d.ts +0 -0
  225. /package/dist/types/src/{processor → chat-model}/prompt.test.d.ts +0 -0
  226. /package/dist/types/src/{processor → chat-model}/streaming.node.test.d.ts +0 -0
  227. /package/dist/types/src/{processor → chat-model}/thread.d.ts +0 -0
  228. /package/dist/types/src/{processor → chat-model}/thread.test.d.ts +0 -0
  229. /package/src/{processor → chat-model}/outbox.test.ts +0 -0
  230. /package/src/{processor → chat-model}/outbox.ts +0 -0
  231. /package/src/{processor → chat-model}/presets.test.ts +0 -0
  232. /package/src/{processor → chat-model}/presets.ts +0 -0
  233. /package/src/{processor → chat-model}/prompt.test.ts +0 -0
  234. /package/src/{processor → chat-model}/thread.ts +0 -0
@@ -3,45 +3,34 @@
3
3
  //
4
4
 
5
5
  import { describe, it } from '@effect/vitest';
6
- import * as Context from 'effect/Context';
7
6
  import * as Deferred from 'effect/Deferred';
8
7
  import * as Effect from 'effect/Effect';
9
- import * as Layer from 'effect/Layer';
10
- import * as ManagedRuntime from 'effect/ManagedRuntime';
11
8
  import * as Atom from 'effect/reactivity/Atom';
12
9
  import * as AtomRegistry from 'effect/reactivity/AtomRegistry';
13
10
  import * as Stream from 'effect/Stream';
14
11
 
15
12
  import { AssistantTestLayer } from '@dxos/agent-runtime/testing';
16
- import { AiService, OpaqueToolkit } from '@dxos/ai';
17
- import * as Capabilities from '@dxos/app-framework/Capabilities';
18
- import * as Capability from '@dxos/app-framework/Capability';
19
- import * as Plugin from '@dxos/app-framework/Plugin';
20
- import * as PluginManager from '@dxos/app-framework/PluginManager';
21
13
  import { AiSession } from '@dxos/assistant';
22
14
  import * as Chat from '@dxos/assistant/Chat';
23
- import { ProcessManager } from '@dxos/compute-runtime';
24
15
  import * as AgentService from '@dxos/compute/AgentService';
25
- import * as Credential from '@dxos/compute/Credential';
26
- import * as Operation from '@dxos/compute/Operation';
27
- import * as ServiceResolver from '@dxos/compute/ServiceResolver';
28
16
  import * as Trace from '@dxos/compute/Trace';
29
- import { Database, Feed, Obj, Ref, Registry } from '@dxos/echo';
17
+ import { Database, Feed, Obj, Ref } from '@dxos/echo';
30
18
  import * as EffectEx from '@dxos/effect/EffectEx';
31
19
  import { TestHelpers } from '@dxos/effect/testing';
32
20
  import { type ContentBlock, type Message } from '@dxos/types';
33
21
 
34
- import { AiChatProcessor } from './processor.ts';
22
+ import { ChatModel } from './chat-model.ts';
23
+ import { makeStubSpaceLayer, makeTestRuntime } from './testing/harness.ts';
35
24
 
36
25
  const TestLayer = AssistantTestLayer({ tracing: 'noop', types: [Chat.Chat, Feed.Feed] });
37
26
 
38
- describe('AiChatProcessor streaming', () => {
27
+ describe('ChatModel streaming', () => {
39
28
  it.effect(
40
29
  'upserts partials, finalizes complete blocks, ignores late partials, and flushes on completion',
41
30
  Effect.fn(
42
31
  function* ({ expect }) {
43
32
  const feed = yield* Database.add(Feed.make());
44
- // The processor resolves its agent session from the chat it runs on.
33
+ // The chat model resolves its agent session from the chat it runs on.
45
34
  const chat = yield* Database.add(Chat.make({ feed: Ref.make(feed) }));
46
35
  const runtime = yield* Effect.context<Database.Service>();
47
36
  const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
@@ -62,11 +51,11 @@ describe('AiChatProcessor streaming', () => {
62
51
  getSession: () => Effect.succeed(stubSession),
63
52
  hydrate: () => Effect.void,
64
53
  };
65
- const spaceLayer = yield* makeSpaceLayer(stubAgentService);
54
+ const spaceLayer = yield* makeStubSpaceLayer(stubAgentService);
66
55
 
67
56
  const observableRegistry = AtomRegistry.make();
68
- const processorRuntime = yield* makeTestRuntime;
69
- const processor = new AiChatProcessor(session, processorRuntime, feed, spaceLayer, {
57
+ const chatModelRuntime = yield* makeTestRuntime;
58
+ const chatModel = new ChatModel(session, chatModelRuntime, feed, spaceLayer, {
70
59
  chat: Ref.make(chat),
71
60
  observableRegistry,
72
61
  });
@@ -78,27 +67,27 @@ describe('AiChatProcessor streaming', () => {
78
67
  const snapshots: string[][] = [];
79
68
  const unsubscribers = [
80
69
  observableRegistry.subscribe(
81
- processor.messages,
70
+ chatModel.messages,
82
71
  (messages) =>
83
72
  snapshots.push(
84
73
  messages.map((message) => message.blocks.map((block) => (block as ContentBlock.Text).text).join('')),
85
74
  ),
86
75
  { immediate: true },
87
76
  ),
88
- observableRegistry.subscribe(processor.error, () => {}),
89
- observableRegistry.subscribe(processor.active, () => {}),
77
+ observableRegistry.subscribe(chatModel.error, () => {}),
78
+ observableRegistry.subscribe(chatModel.active, () => {}),
90
79
  ];
91
80
  yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribers.forEach((unsubscribe) => unsubscribe())));
92
81
 
93
- yield* Effect.promise(() => processor.request({ message: 'Hello?' }));
82
+ yield* Effect.promise(() => chatModel.request({ message: 'Hello?' }));
94
83
 
95
84
  // Surface any swallowed request failure before asserting on state.
96
- const error = observableRegistry.get(processor.error);
85
+ const error = observableRegistry.get(chatModel.error);
97
86
  expect(error._tag === 'Some' ? `${error.value.message}: ${error.value.cause}` : undefined).toBeUndefined();
98
87
 
99
88
  // m1 was finalized exactly once (the late partial after finalization is dropped); m2's
100
89
  // unfinalized partial was flushed into the pending list when the agent completed.
101
- const messages = observableRegistry.get(processor.messages);
90
+ const messages = observableRegistry.get(chatModel.messages);
102
91
  expect(messages.map(({ id }) => id)).toEqual([m1, m2]);
103
92
  expect(texts(messages)).toEqual(['Hello world.', 'Working…']);
104
93
 
@@ -111,8 +100,8 @@ describe('AiChatProcessor streaming', () => {
111
100
  expect(snapshots.flat()).not.toContain('Hello world. (stale)');
112
101
 
113
102
  // The request settled cleanly: nothing left streaming, no error, inactive.
114
- expect(observableRegistry.get(processor.streaming)).toBe(false);
115
- expect(observableRegistry.get(processor.active)).toBe(false);
103
+ expect(observableRegistry.get(chatModel.streaming)).toBe(false);
104
+ expect(observableRegistry.get(chatModel.active)).toBe(false);
116
105
  },
117
106
  Effect.provide(TestLayer),
118
107
  TestHelpers.provideTestContext,
@@ -124,7 +113,7 @@ describe('AiChatProcessor streaming', () => {
124
113
  Effect.fn(
125
114
  function* ({ expect }) {
126
115
  const feed = yield* Database.add(Feed.make());
127
- // The processor resolves its agent session from the chat it runs on.
116
+ // The chat model resolves its agent session from the chat it runs on.
128
117
  const chat = yield* Database.add(Chat.make({ feed: Ref.make(feed) }));
129
118
  const runtime = yield* Effect.context<Database.Service>();
130
119
  const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
@@ -137,33 +126,33 @@ describe('AiChatProcessor streaming', () => {
137
126
 
138
127
  const idleSession = yield* makeStubSession(chat, feed, batches, { running: false });
139
128
  const idleRegistry = AtomRegistry.make();
140
- const idleProcessor = new AiChatProcessor(
129
+ const idleChatModel = new ChatModel(
141
130
  session,
142
131
  yield* makeTestRuntime,
143
132
  feed,
144
- yield* makeSpaceLayer({
133
+ yield* makeStubSpaceLayer({
145
134
  getSession: () => Effect.succeed(idleSession),
146
135
  hydrate: () => Effect.void,
147
136
  }),
148
137
  { chat: Ref.make(chat), observableRegistry: idleRegistry },
149
138
  );
150
139
  const idleUnsubscribers = [
151
- idleRegistry.subscribe(idleProcessor.messages, () => {}, { immediate: true }),
152
- idleProcessor.adopt(),
140
+ idleRegistry.subscribe(idleChatModel.messages, () => {}, { immediate: true }),
141
+ idleChatModel.adopt(),
153
142
  ];
154
143
  yield* Effect.addFinalizer(() => Effect.sync(() => idleUnsubscribers.forEach((dispose) => dispose())));
155
144
  yield* quiesce;
156
145
  // A live-but-idle agent (awaiting input) is not adopted.
157
- expect(idleRegistry.get(idleProcessor.active)).toBe(false);
158
- expect(idleRegistry.get(idleProcessor.messages)).toEqual([]);
146
+ expect(idleRegistry.get(idleChatModel.active)).toBe(false);
147
+ expect(idleRegistry.get(idleChatModel.messages)).toEqual([]);
159
148
 
160
149
  const runningSession = yield* makeStubSession(chat, feed, batches, { running: true });
161
150
  const observableRegistry = AtomRegistry.make();
162
- const processor = new AiChatProcessor(
151
+ const chatModel = new ChatModel(
163
152
  session,
164
153
  yield* makeTestRuntime,
165
154
  feed,
166
- yield* makeSpaceLayer({
155
+ yield* makeStubSpaceLayer({
167
156
  getSession: () => Effect.succeed(runningSession),
168
157
  hydrate: () => Effect.void,
169
158
  }),
@@ -172,19 +161,19 @@ describe('AiChatProcessor streaming', () => {
172
161
 
173
162
  const activeSnapshots: boolean[] = [];
174
163
  const unsubscribers = [
175
- observableRegistry.subscribe(processor.messages, () => {}, { immediate: true }),
176
- observableRegistry.subscribe(processor.active, (active) => activeSnapshots.push(active), {
164
+ observableRegistry.subscribe(chatModel.messages, () => {}, { immediate: true }),
165
+ observableRegistry.subscribe(chatModel.active, (active) => activeSnapshots.push(active), {
177
166
  immediate: true,
178
167
  }),
179
168
  ];
180
169
  yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribers.forEach((unsubscribe) => unsubscribe())));
181
170
 
182
- unsubscribers.push(processor.adopt());
183
- yield* whenAtom(observableRegistry, processor.active, (active) => !active && activeSnapshots.includes(true));
171
+ unsubscribers.push(chatModel.adopt());
172
+ yield* whenAtom(observableRegistry, chatModel.active, (active) => !active && activeSnapshots.includes(true));
184
173
 
185
174
  expect(activeSnapshots).toContain(true);
186
- expect(observableRegistry.get(processor.active)).toBe(false);
187
- expect(texts(observableRegistry.get(processor.messages))).toEqual(['Still working…']);
175
+ expect(observableRegistry.get(chatModel.active)).toBe(false);
176
+ expect(texts(observableRegistry.get(chatModel.messages))).toEqual(['Still working…']);
188
177
  },
189
178
  Effect.provide(TestLayer),
190
179
  TestHelpers.provideTestContext,
@@ -196,7 +185,7 @@ describe('AiChatProcessor streaming', () => {
196
185
  Effect.fn(
197
186
  function* ({ expect }) {
198
187
  const feed = yield* Database.add(Feed.make());
199
- // The processor resolves its agent session from the chat it runs on.
188
+ // The chat model resolves its agent session from the chat it runs on.
200
189
  const chat = yield* Database.add(Chat.make({ feed: Ref.make(feed) }));
201
190
  const runtime = yield* Effect.context<Database.Service>();
202
191
  const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
@@ -210,30 +199,30 @@ describe('AiChatProcessor streaming', () => {
210
199
  );
211
200
 
212
201
  const observableRegistry = AtomRegistry.make();
213
- const processor = new AiChatProcessor(
202
+ const chatModel = new ChatModel(
214
203
  session,
215
204
  yield* makeTestRuntime,
216
205
  feed,
217
- yield* makeSpaceLayer({
206
+ yield* makeStubSpaceLayer({
218
207
  getSession: () => Effect.succeed(gated),
219
208
  hydrate: () => Effect.void,
220
209
  }),
221
210
  { chat: Ref.make(chat), observableRegistry },
222
211
  );
223
212
 
224
- const unsubscribe = observableRegistry.subscribe(processor.messages, () => {}, { immediate: true });
213
+ const unsubscribe = observableRegistry.subscribe(chatModel.messages, () => {}, { immediate: true });
225
214
  yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribe()));
226
215
 
227
- const dispose = processor.adopt();
228
- yield* whenAtom(observableRegistry, processor.messages, (messages) => messages.length > 0);
229
- expect(texts(observableRegistry.get(processor.messages))).toEqual(['Still']);
216
+ const dispose = chatModel.adopt();
217
+ yield* whenAtom(observableRegistry, chatModel.messages, (messages) => messages.length > 0);
218
+ expect(texts(observableRegistry.get(chatModel.messages))).toEqual(['Still']);
230
219
 
231
220
  dispose();
232
221
  yield* release;
233
222
  yield* quiesce;
234
223
 
235
- expect(texts(observableRegistry.get(processor.messages))).toEqual(['Still']);
236
- expect(observableRegistry.get(processor.active)).toBe(false);
224
+ expect(texts(observableRegistry.get(chatModel.messages))).toEqual(['Still']);
225
+ expect(observableRegistry.get(chatModel.active)).toBe(false);
237
226
  // The session still reports a turn in flight, so the observer stopped because it was
238
227
  // disposed rather than because the turn settled.
239
228
  expect(observableRegistry.get(gated.running)).toBe(true);
@@ -248,7 +237,7 @@ describe('AiChatProcessor streaming', () => {
248
237
  Effect.fn(
249
238
  function* ({ expect }) {
250
239
  const feed = yield* Database.add(Feed.make());
251
- // The processor resolves its agent session from the chat it runs on.
240
+ // The chat model resolves its agent session from the chat it runs on.
252
241
  const chat = yield* Database.add(Chat.make({ feed: Ref.make(feed) }));
253
242
  const runtime = yield* Effect.context<Database.Service>();
254
243
  const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
@@ -266,11 +255,11 @@ describe('AiChatProcessor streaming', () => {
266
255
  getSession: () => Effect.succeed(stubSession),
267
256
  hydrate: () => Effect.void,
268
257
  };
269
- const spaceLayer = yield* makeSpaceLayer(stubAgentService);
258
+ const spaceLayer = yield* makeStubSpaceLayer(stubAgentService);
270
259
 
271
260
  const observableRegistry = AtomRegistry.make();
272
- const processorRuntime = yield* makeTestRuntime;
273
- const processor = new AiChatProcessor(session, processorRuntime, feed, spaceLayer, {
261
+ const chatModelRuntime = yield* makeTestRuntime;
262
+ const chatModel = new ChatModel(session, chatModelRuntime, feed, spaceLayer, {
274
263
  chat: Ref.make(chat),
275
264
  observableRegistry,
276
265
  });
@@ -278,7 +267,7 @@ describe('AiChatProcessor streaming', () => {
278
267
  const snapshots: (string | undefined)[] = [];
279
268
  const attempts: (number | undefined)[] = [];
280
269
  const unsubscribe = observableRegistry.subscribe(
281
- processor.activity,
270
+ chatModel.activity,
282
271
  (activity) => {
283
272
  snapshots.push(activity?.phase);
284
273
  attempts.push(activity?.attempt);
@@ -287,9 +276,9 @@ describe('AiChatProcessor streaming', () => {
287
276
  );
288
277
  yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribe()));
289
278
 
290
- yield* Effect.promise(() => processor.request({ message: 'Hello?' }));
279
+ yield* Effect.promise(() => chatModel.request({ message: 'Hello?' }));
291
280
 
292
- const error = observableRegistry.get(processor.error);
281
+ const error = observableRegistry.get(chatModel.error);
293
282
  expect(error._tag === 'Some' ? `${error.value.message}: ${error.value.cause}` : undefined).toBeUndefined();
294
283
 
295
284
  // `starting` is set locally before the process resolves, so it precedes anything the agent
@@ -307,7 +296,63 @@ describe('AiChatProcessor streaming', () => {
307
296
  expect(snapshots.indexOf('contacting-provider')).toBeLessThan(snapshots.indexOf('generating'));
308
297
  expect(snapshots.filter((phase) => phase === undefined)).toHaveLength(2);
309
298
  expect(snapshots.at(-1)).toBeUndefined();
310
- expect(observableRegistry.get(processor.activity)).toBeUndefined();
299
+ expect(observableRegistry.get(chatModel.activity)).toBeUndefined();
300
+ },
301
+ Effect.provide(TestLayer),
302
+ TestHelpers.provideTestContext,
303
+ ),
304
+ );
305
+
306
+ it.effect(
307
+ 'ignores phases and partials that arrive after newer ones',
308
+ Effect.fn(
309
+ function* ({ expect }) {
310
+ const feed = yield* Database.add(Feed.make());
311
+ const chat = yield* Database.add(Chat.make({ feed: Ref.make(feed) }));
312
+ const runtime = yield* Effect.context<Database.Service>();
313
+ const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
314
+
315
+ // The swarm relay reorders: each stale event follows the newer one it predates.
316
+ const messageId = Obj.ID.random();
317
+ const batches = [
318
+ traceMessage([requestPhaseEvent('contacting-provider', undefined, 30)]),
319
+ traceMessage([requestPhaseEvent('encoding-prompt', undefined, 20)]),
320
+ traceMessage([partialBlockEvent(messageId, 'Hello', true, 40)]),
321
+ traceMessage([partialBlockEvent(messageId, 'Hel', true, 35)]),
322
+ traceMessage([partialBlockEvent(messageId, 'Hello world.', false, 50)]),
323
+ // A tool phase, then a block of a message not seen yet but produced before it.
324
+ traceMessage([requestPhaseEvent('calling-tool', undefined, 70)]),
325
+ traceMessage([partialBlockEvent(Obj.ID.random(), 'Earlier', true, 60)]),
326
+ ];
327
+ const stubSession = yield* makeStubSession(chat, feed, batches);
328
+ const observableRegistry = AtomRegistry.make();
329
+ const chatModel = new ChatModel(
330
+ session,
331
+ yield* makeTestRuntime,
332
+ feed,
333
+ yield* makeStubSpaceLayer({ getSession: () => Effect.succeed(stubSession), hydrate: () => Effect.void }),
334
+ { chat: Ref.make(chat), observableRegistry },
335
+ );
336
+
337
+ const phases: (string | undefined)[] = [];
338
+ const streamed: string[][] = [];
339
+ const unsubscribers = [
340
+ observableRegistry.subscribe(chatModel.activity, (activity) => phases.push(activity?.phase), {
341
+ immediate: true,
342
+ }),
343
+ observableRegistry.subscribe(chatModel.messages, (messages) => streamed.push(texts(messages)), {
344
+ immediate: true,
345
+ }),
346
+ ];
347
+ yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribers.forEach((unsubscribe) => unsubscribe())));
348
+
349
+ yield* Effect.promise(() => chatModel.request({ message: 'Hello?' }));
350
+
351
+ expect(phases).not.toContain('encoding-prompt');
352
+ // The older block still streams, but the line keeps naming the tool rather than `generating`.
353
+ expect(phases.slice(phases.indexOf('calling-tool'))).not.toContain('generating');
354
+ expect(streamed).not.toContainEqual(['Hel']);
355
+ expect(texts(observableRegistry.get(chatModel.messages))).toEqual(['Hello world.', 'Earlier']);
311
356
  },
312
357
  Effect.provide(TestLayer),
313
358
  TestHelpers.provideTestContext,
@@ -320,15 +365,15 @@ describe('AiChatProcessor streaming', () => {
320
365
  //
321
366
 
322
367
  /** Builds a trace event carrying a request setup phase (the payload the activity atom consumes). */
323
- const requestPhaseEvent = (phase: Trace.RequestPhaseName, attempt?: number): Trace.Event => ({
324
- timestamp: 0,
368
+ const requestPhaseEvent = (phase: Trace.RequestPhaseName, attempt?: number, timestamp = 0): Trace.Event => ({
369
+ timestamp,
325
370
  type: Trace.RequestPhase.key,
326
371
  data: { phase, ...(attempt !== undefined ? { attempt } : {}) },
327
372
  });
328
373
 
329
374
  /** Builds a trace event carrying an assistant text block (the payload `#handleEphemeralMessage` consumes). */
330
- const partialBlockEvent = (messageId: string, text: string, pending: boolean): Trace.Event => ({
331
- timestamp: 0,
375
+ const partialBlockEvent = (messageId: string, text: string, pending: boolean, timestamp = 0): Trace.Event => ({
376
+ timestamp,
332
377
  type: Trace.PartialBlock.key,
333
378
  data: {
334
379
  messageId,
@@ -433,56 +478,3 @@ const makeGatedSession = (
433
478
  };
434
479
  return { session, release: Deferred.succeed(gate, undefined).pipe(Effect.asVoid) };
435
480
  });
436
-
437
- /** The processor's space layer: the test layer's real services with the stub agent service swapped in. */
438
- const makeSpaceLayer = (agentService: AgentService.Service) =>
439
- Effect.gen(function* () {
440
- const services = yield* Effect.context<
441
- | Database.Service
442
- | Credential.CredentialsService
443
- | AiService.AiService
444
- | Registry.Service
445
- | OpaqueToolkit.OpaqueToolkitProvider
446
- >();
447
- return Layer.mergeAll(
448
- Layer.succeedContext(
449
- Context.pick(
450
- Database.Service,
451
- Credential.CredentialsService,
452
- AiService.AiService,
453
- Registry.Service,
454
- OpaqueToolkit.OpaqueToolkitProvider,
455
- )(services),
456
- ),
457
- Layer.succeed(AgentService.AgentService, agentService),
458
- );
459
- });
460
-
461
- /**
462
- * A sound {@link Capabilities.ProcessManagerRuntime} for tests: a `ManagedRuntime` over the
463
- * assistant test layer's process services plus a bare `PluginManager` for the capability/plugin
464
- * tags. Scoped so the runtime is disposed with the test.
465
- */
466
- const makeTestRuntime = Effect.gen(function* () {
467
- const services = yield* Effect.context<
468
- | ProcessManager.Service
469
- | Operation.Service
470
- | ProcessManager.ProcessOperationInvoker.Service
471
- | ServiceResolver.ServiceResolver
472
- >();
473
- const manager = PluginManager.make({
474
- pluginLoader: (id: string) => Effect.die(new Error(`No plugins in test runtime: ${id}`)),
475
- plugins: [],
476
- });
477
- const runtime: Capabilities.ProcessManagerRuntime = ManagedRuntime.make(
478
- Layer.mergeAll(
479
- Layer.succeedContext(services),
480
- Layer.succeed(Capability.Service, manager.capabilities),
481
- Layer.succeed(Plugin.Service, manager),
482
- ),
483
- );
484
- yield* Effect.addFinalizer(() =>
485
- Effect.promise(() => (runtime as ManagedRuntime.ManagedRuntime<never, never>).dispose()),
486
- );
487
- return runtime;
488
- });
@@ -0,0 +1,249 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Option from 'effect/Option';
6
+ import type * as Atom from 'effect/reactivity/Atom';
7
+ import type * as AtomRegistry from 'effect/reactivity/AtomRegistry';
8
+
9
+ import type * as Trace from '@dxos/compute/Trace';
10
+ import { type ContentBlock, type Message } from '@dxos/types';
11
+
12
+ import { type ChatModel } from '../chat-model.ts';
13
+ import { type ThreadProjection } from '../thread.ts';
14
+
15
+ /** The observable outputs of a {@link ChatModel} the UI binds to, plus the agent's own view of its turn. */
16
+ export type ChatModelSignal = 'active' | 'streaming' | 'activity' | 'error' | 'thread' | 'agent';
17
+
18
+ /** One change to one signal, as the UI would have seen it. */
19
+ export type ChatModelEvent = {
20
+ /** Milliseconds since the recorder was attached. */
21
+ readonly at: number;
22
+ readonly signal: ChatModelSignal;
23
+ /** Compact rendering of the new value, stable enough to assert on. */
24
+ readonly value: string;
25
+ };
26
+
27
+ /** A moment at which the indicators disagreed with the work in flight. */
28
+ export type ChatModelViolation = {
29
+ readonly at: number;
30
+ readonly rule: string;
31
+ readonly state: Readonly<Record<ChatModelSignal, string>>;
32
+ };
33
+
34
+ export type ChatModelRecorderOptions = {
35
+ /**
36
+ * The agent session's `running` atom. Recorded as the `agent` signal and checked against `active`:
37
+ * an agent working while the chat reads idle is exactly the missing-indicator failure.
38
+ */
39
+ readonly agentRunning?: Atom.Atom<boolean>;
40
+ /** Clock for {@link ChatModelEvent.at}; injectable so a report can share a time base. */
41
+ readonly now?: () => number;
42
+ };
43
+
44
+ const IDLE = '-';
45
+ const UNSET = '';
46
+
47
+ /**
48
+ * Records every change to a {@link ChatModel}'s indicator atoms as an ordered event log and checks
49
+ * the indicator invariants after each burst of changes.
50
+ *
51
+ * Invariants are evaluated on a microtask rather than per write: the chat model settles several atoms
52
+ * in one synchronous sequence (e.g. `active` then `activity`), which React renders as one frame, so
53
+ * only a state that survives the burst is one a reader could see.
54
+ */
55
+ export class ChatModelRecorder {
56
+ readonly events: ChatModelEvent[] = [];
57
+ readonly violations: ChatModelViolation[] = [];
58
+
59
+ readonly #registry: AtomRegistry.AtomRegistry;
60
+ readonly #now: () => number;
61
+ readonly #start: number;
62
+ // Unset rather than idle, so each signal's initial value is the first event in its log.
63
+ readonly #state: Record<ChatModelSignal, string> = {
64
+ active: UNSET,
65
+ streaming: UNSET,
66
+ activity: UNSET,
67
+ error: UNSET,
68
+ thread: UNSET,
69
+ agent: UNSET,
70
+ };
71
+ readonly #unsubscribers: (() => void)[] = [];
72
+ readonly #listeners = new Set<() => void>();
73
+ #checkScheduled = false;
74
+
75
+ constructor(registry: AtomRegistry.AtomRegistry, chatModel: ChatModel, options: ChatModelRecorderOptions = {}) {
76
+ this.#registry = registry;
77
+ this.#now = options.now ?? (() => performance.now());
78
+ this.#start = this.#now();
79
+ this.#watch('active', chatModel.active, String);
80
+ this.#watch('streaming', chatModel.streaming, String);
81
+ this.#watch('activity', chatModel.activity, formatActivity);
82
+ this.#watch('error', chatModel.error, (error) =>
83
+ Option.match(error, { onNone: () => IDLE, onSome: (error) => error.message }),
84
+ );
85
+ this.#watch('thread', chatModel.thread, formatThread);
86
+ if (options.agentRunning) {
87
+ this.#watch('agent', options.agentRunning, (running) => (running ? 'running' : 'idle'));
88
+ }
89
+ }
90
+
91
+ /** The latest value of every signal. */
92
+ get state(): Readonly<Record<ChatModelSignal, string>> {
93
+ return { ...this.#state };
94
+ }
95
+
96
+ /** The log as `signal=value` lines, optionally narrowed to some signals. */
97
+ lines(signals?: readonly ChatModelSignal[]): string[] {
98
+ return this.events
99
+ .filter((event) => !signals || signals.includes(event.signal))
100
+ .map(({ signal, value }) => `${signal}=${value}`);
101
+ }
102
+
103
+ /** Distinct consecutive values a signal took, from its first recorded value. */
104
+ values(signal: ChatModelSignal): string[] {
105
+ return this.events.filter((event) => event.signal === signal).map(({ value }) => value);
106
+ }
107
+
108
+ /** When `signal` first took `value` (or first changed, without one), relative to attach. */
109
+ firstAt(signal: ChatModelSignal, value?: string | ((value: string) => boolean)): number | undefined {
110
+ const match =
111
+ value === undefined
112
+ ? () => true
113
+ : typeof value === 'function'
114
+ ? value
115
+ : (candidate: string) => candidate === value;
116
+ return this.events.find((event) => event.signal === signal && match(event.value))?.at;
117
+ }
118
+
119
+ /** Milliseconds since the recorder was attached. */
120
+ elapsed(): number {
121
+ return this.#now() - this.#start;
122
+ }
123
+
124
+ /** Resolves once `predicate` holds for the current state (checked now and after every change). */
125
+ until(predicate: (state: Readonly<Record<ChatModelSignal, string>>) => boolean, timeout = 30_000): Promise<void> {
126
+ return new Promise((resolve, reject) => {
127
+ const check = () => {
128
+ if (predicate(this.#state)) {
129
+ cleanup();
130
+ resolve();
131
+ }
132
+ };
133
+ const timer = setTimeout(() => {
134
+ cleanup();
135
+ const log = this.events.map(({ at, signal, value }) => ` ${at}ms ${signal}=${value}`).join('\n');
136
+ reject(new Error(`ChatModelRecorder.until timed out; state=${JSON.stringify(this.#state)}\n${log}`));
137
+ }, timeout);
138
+ const cleanup = () => {
139
+ clearTimeout(timer);
140
+ this.#listeners.delete(check);
141
+ };
142
+ this.#listeners.add(check);
143
+ check();
144
+ });
145
+ }
146
+
147
+ /** Resolves once the chat model has been active and is idle again. */
148
+ untilSettled(timeout?: number): Promise<void> {
149
+ return this.until((state) => this.values('active').includes('true') && state.active === 'false', timeout);
150
+ }
151
+
152
+ dispose(): void {
153
+ this.#unsubscribers.forEach((unsubscribe) => unsubscribe());
154
+ this.#unsubscribers.length = 0;
155
+ this.#listeners.clear();
156
+ }
157
+
158
+ #watch<T>(signal: ChatModelSignal, atom: Atom.Atom<T>, format: (value: T) => string): void {
159
+ this.#unsubscribers.push(
160
+ this.#registry.subscribe(
161
+ atom,
162
+ (value) => {
163
+ const formatted = format(value);
164
+ if (formatted === this.#state[signal]) {
165
+ return;
166
+ }
167
+ this.#state[signal] = formatted;
168
+ this.events.push({ at: Math.round(this.elapsed()), signal, value: formatted });
169
+ this.#scheduleCheck();
170
+ this.#listeners.forEach((listener) => listener());
171
+ },
172
+ // Mounts the atom: the chat model's derived atoms hold no state until something reads them.
173
+ { immediate: true },
174
+ ),
175
+ );
176
+ }
177
+
178
+ #scheduleCheck(): void {
179
+ if (this.#checkScheduled) {
180
+ return;
181
+ }
182
+ this.#checkScheduled = true;
183
+ queueMicrotask(() => {
184
+ this.#checkScheduled = false;
185
+ this.#check();
186
+ });
187
+ }
188
+
189
+ #check(): void {
190
+ const state = this.#state;
191
+ const fail = (rule: string) => this.violations.push({ at: Math.round(this.elapsed()), rule, state: { ...state } });
192
+ if (state.streaming === 'true' && state.active !== 'true') {
193
+ fail('streaming while inactive');
194
+ }
195
+ if (state.activity !== IDLE && state.active !== 'true') {
196
+ fail('activity line while inactive');
197
+ }
198
+ if (state.active === 'true' && state.activity === IDLE) {
199
+ fail('active without an activity line');
200
+ }
201
+ if (state.agent === 'running' && state.active !== 'true') {
202
+ fail('agent running while chat reads idle');
203
+ }
204
+ }
205
+ }
206
+
207
+ const formatActivity = (activity: Trace.PayloadType<typeof Trace.RequestPhase> | undefined): string => {
208
+ if (!activity) {
209
+ return IDLE;
210
+ }
211
+ const detail = activity.detail ? `(${activity.detail})` : '';
212
+ const attempt = activity.attempt !== undefined && activity.attempt > 1 ? `#${activity.attempt}` : '';
213
+ return `${activity.phase}${detail}${attempt}`;
214
+ };
215
+
216
+ const MAX_TEXT = 24;
217
+
218
+ const formatBlock = (block: ContentBlock.Any): string => {
219
+ switch (block._tag) {
220
+ case 'text': {
221
+ const text = block.text.replace(/\s+/g, ' ').trim();
222
+ return JSON.stringify(text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text);
223
+ }
224
+ case 'toolCall':
225
+ return `call(${block.name})`;
226
+ default:
227
+ return block._tag;
228
+ }
229
+ };
230
+
231
+ /** Blocks that carry bookkeeping rather than anything the reader sees in the turn. */
232
+ const HIDDEN_BLOCKS = new Set(['stats']);
233
+
234
+ const formatMessage = (message: Message.Message, thread: ThreadProjection): string | undefined => {
235
+ const blocks = message.blocks.filter((block) => !HIDDEN_BLOCKS.has(block._tag));
236
+ if (blocks.length === 0) {
237
+ return undefined;
238
+ }
239
+ const delivery = thread.delivery.get(message.id);
240
+ const role = message.sender.role ?? '?';
241
+ const status = delivery ? `[${delivery.status}]` : '';
242
+ return `${role}${status}:${blocks.map(formatBlock).join('+')}`;
243
+ };
244
+
245
+ /** One entry per visible row: the role, the delivery status of a prompt row, and its blocks. */
246
+ const formatThread = (thread: ThreadProjection): string => {
247
+ const rows = thread.messages.flatMap((message) => formatMessage(message, thread) ?? []);
248
+ return rows.length === 0 ? IDLE : rows.join(' | ');
249
+ };