@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
@@ -0,0 +1,194 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { beforeEach, describe, expect, it } from '@effect/vitest';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Function from 'effect/Function';
8
+ import * as Layer from 'effect/Layer';
9
+ import * as Atom from 'effect/reactivity/Atom';
10
+ import * as AtomRegistry from 'effect/reactivity/AtomRegistry';
11
+
12
+ import { AgentService } from '@dxos/agent-runtime';
13
+ import { AssistantTestLayer } from '@dxos/agent-runtime/testing';
14
+ import { ScriptedLanguageModel } from '@dxos/ai/testing';
15
+ import * as Capabilities from '@dxos/app-framework/Capabilities';
16
+ import * as Capability from '@dxos/app-framework/Capability';
17
+ import * as CapabilityManager from '@dxos/app-framework/CapabilityManager';
18
+ import * as Chat from '@dxos/assistant/Chat';
19
+ import { ProcessManager } from '@dxos/compute-runtime';
20
+ import * as Process from '@dxos/compute/Process';
21
+ import * as Skill from '@dxos/compute/Skill';
22
+ import { Feed, Obj } from '@dxos/echo';
23
+ import { TestHelpers } from '@dxos/effect/testing';
24
+ import { DXN, EntityId } from '@dxos/keys';
25
+ import * as Observability from '@dxos/observability/Observability';
26
+ import * as ObservabilityCapabilities from '@dxos/plugin-observability/ObservabilityCapabilities';
27
+ import { Message } from '@dxos/types';
28
+
29
+ import { AssistantOperationHandlerSet } from '#operations';
30
+ import { TurnReviewSkill } from '#skills';
31
+ import { type Assistant, AssistantCapabilities } from '#types';
32
+
33
+ import { REVIEW_SYSTEM_PROMPT, STRUGGLE_EVENT, type TurnVerdict } from '../review/turn-review.ts';
34
+
35
+ EntityId.dangerouslyDisableRandomness();
36
+
37
+ const VERDICT: TurnVerdict = {
38
+ struggled: true,
39
+ cause: 'tool_faulty',
40
+ severity: 'medium',
41
+ summary: 'The search tool rejected a valid query.',
42
+ tools: ['search'],
43
+ };
44
+
45
+ type Captured = {
46
+ events: { event: string; attributes?: Record<string, unknown> }[];
47
+ uploads: { ndjson: string; kind: string }[];
48
+ };
49
+
50
+ let captured: Captured = { events: [], uploads: [] };
51
+ let telemetryEnabled = true;
52
+
53
+ /** A real {@link Observability} over one recording extension, so the handler sees the production API. */
54
+ const observability = Effect.runSync(
55
+ Function.pipe(
56
+ Observability.make(),
57
+ Observability.addExtension(
58
+ Effect.succeed({
59
+ get enabled() {
60
+ return telemetryEnabled;
61
+ },
62
+ apis: [
63
+ {
64
+ kind: 'events',
65
+ isAvailable: () => Effect.succeed(true),
66
+ captureEvent: (event, attributes) => void captured.events.push({ event, attributes }),
67
+ },
68
+ {
69
+ kind: 'support',
70
+ isAvailable: () => Effect.succeed(true),
71
+ uploadLogs: async () => undefined,
72
+ uploadNdjson: async (ndjson, kind) => {
73
+ captured.uploads.push({ ndjson, kind });
74
+ return 'trajectories/2026-10-06/test.ndjson.gz';
75
+ },
76
+ sessionContext: () => undefined,
77
+ flushLogs: async () => {},
78
+ },
79
+ ],
80
+ }),
81
+ ),
82
+ ),
83
+ );
84
+
85
+ // One manager for the file: the test layer resolves `Capability.Service` before a test body runs, so
86
+ // each test varies the state the manager holds rather than the manager itself.
87
+ const registry = AtomRegistry.make();
88
+ const settingsAtom = Atom.make<Assistant.Settings>({}).pipe(Atom.keepAlive);
89
+ const manager = CapabilityManager.make({ registry });
90
+ manager.contribute({ module: 'test', interface: Capabilities.AtomRegistry, implementation: registry });
91
+ manager.contribute({ module: 'test', interface: AssistantCapabilities.Settings, implementation: settingsAtom });
92
+ manager.contribute({
93
+ module: 'test',
94
+ interface: ObservabilityCapabilities.Observability,
95
+ implementation: observability,
96
+ });
97
+
98
+ const setup = ({ reportStruggles, telemetry }: { reportStruggles: boolean; telemetry: boolean }) => {
99
+ registry.set(settingsAtom, { reportStruggles, codeMode: true });
100
+ telemetryEnabled = telemetry;
101
+ };
102
+
103
+ const TestLayer = AssistantTestLayer({
104
+ operationHandlers: AssistantOperationHandlerSet,
105
+ types: [Chat.Chat, Feed.Feed, Message.Message, Skill.Skill],
106
+ skills: [TurnReviewSkill.make()],
107
+ // The reviewer gets the verdict; the conversational turn gets a plain reply.
108
+ aiService: ScriptedLanguageModel.scriptedAiService((request) => ({
109
+ parts: [
110
+ ScriptedLanguageModel.text(
111
+ request.system.includes(REVIEW_SYSTEM_PROMPT) ? JSON.stringify(VERDICT) : 'I could not search your notes.',
112
+ ),
113
+ ],
114
+ })),
115
+ extraServices: Layer.succeed(Capability.Service, manager),
116
+ });
117
+
118
+ const MODEL = DXN.make('com.anthropic.model.claude-sonnet-5.default');
119
+
120
+ /** Runs one turn with the turn-review skill bound and waits for the agent process, hooks included, to finish. */
121
+ const runTurn = Effect.fnUntraced(function* () {
122
+ const session = yield* AgentService.createSession({ skills: [TurnReviewSkill.make()], model: MODEL });
123
+ yield* session.submitPrompt('Find my notes.');
124
+ yield* session.waitForCompletion();
125
+ const processManager = yield* ProcessManager.ProcessManagerService;
126
+ // The agent process finishes only after its background hooks, so its exit is the review's.
127
+ const handles = yield* processManager.list({ target: Obj.getURI(session.chat) });
128
+ expect(handles.length).toBeGreaterThan(0);
129
+ yield* Effect.promise(async () => {
130
+ await expect
131
+ .poll(() => handles.every((handle) => handle.status.state === Process.State.SUCCEEDED), { timeout: 10_000 })
132
+ .toBe(true);
133
+ });
134
+ });
135
+
136
+ describe('AnalyzeTurn', () => {
137
+ beforeEach(() => {
138
+ captured = { events: [], uploads: [] };
139
+ });
140
+
141
+ it.effect(
142
+ 'reports a prompting or tooling struggle with its trajectory',
143
+ Effect.fnUntraced(
144
+ function* (_) {
145
+ setup({ reportStruggles: true, telemetry: true });
146
+ yield* runTurn();
147
+
148
+ expect(captured.uploads).toHaveLength(1);
149
+ expect(captured.uploads[0].kind).toBe('trajectory');
150
+ const [header, ...messages] = captured.uploads[0].ndjson.split('\n').map((line) => JSON.parse(line));
151
+ expect(header).toMatchObject({ type: 'header', verdict: { cause: 'tool_faulty' } });
152
+ expect(messages.length).toBeGreaterThan(0);
153
+ expect(messages.every((message) => message.type === 'message')).toBe(true);
154
+ expect(captured.events).toHaveLength(1);
155
+ expect(captured.events[0].event).toBe(STRUGGLE_EVENT);
156
+ expect(captured.events[0].attributes).toMatchObject({
157
+ model: 'dxn:com.anthropic.model.claude-sonnet-5.default',
158
+ code_mode: true,
159
+ cause: 'tool_faulty',
160
+ cause_group: 'tooling',
161
+ trajectory_key: 'trajectories/2026-10-06/test.ndjson.gz',
162
+ });
163
+ },
164
+ Effect.provide(TestLayer),
165
+ TestHelpers.provideTestContext,
166
+ ),
167
+ );
168
+
169
+ it.effect(
170
+ 'does nothing unless the user opted in',
171
+ Effect.fnUntraced(
172
+ function* (_) {
173
+ setup({ reportStruggles: false, telemetry: true });
174
+ yield* runTurn();
175
+ expect(captured).toEqual({ events: [], uploads: [] });
176
+ },
177
+ Effect.provide(TestLayer),
178
+ TestHelpers.provideTestContext,
179
+ ),
180
+ );
181
+
182
+ it.effect(
183
+ 'does nothing while telemetry is off',
184
+ Effect.fnUntraced(
185
+ function* (_) {
186
+ setup({ reportStruggles: true, telemetry: false });
187
+ yield* runTurn();
188
+ expect(captured).toEqual({ events: [], uploads: [] });
189
+ },
190
+ Effect.provide(TestLayer),
191
+ TestHelpers.provideTestContext,
192
+ ),
193
+ );
194
+ });
@@ -0,0 +1,96 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as LanguageModel from 'effect/ai/LanguageModel';
6
+ import * as Prompt from 'effect/ai/Prompt';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Option from 'effect/Option';
9
+
10
+ import { AiService } from '@dxos/ai';
11
+ import * as Capabilities from '@dxos/app-framework/Capabilities';
12
+ import * as Capability from '@dxos/app-framework/Capability';
13
+ import * as Harness from '@dxos/assistant/Harness';
14
+ import * as Operation from '@dxos/compute/Operation';
15
+ import { Database, Obj } from '@dxos/echo';
16
+ import { log } from '@dxos/log';
17
+ import { contentCaptureAllowed } from '@dxos/observability/AiObservability';
18
+ import * as ObservabilityCapabilities from '@dxos/plugin-observability/ObservabilityCapabilities';
19
+
20
+ import { TurnReviewSkill } from '#skills';
21
+ import { AssistantCapabilities } from '#types';
22
+
23
+ import {
24
+ REVIEW_MODEL,
25
+ REVIEW_SYSTEM_PROMPT,
26
+ STRUGGLE_EVENT,
27
+ type TrajectoryHeader,
28
+ TurnVerdict,
29
+ formatReviewPrompt,
30
+ isReportable,
31
+ toStruggleEventProperties,
32
+ toTrajectoryNdjson,
33
+ } from '../review/turn-review.ts';
34
+
35
+ // Never fails: it runs as a background hook after the user's turn, so a review that cannot complete
36
+ // is only logged.
37
+ const handler: Operation.WithHandler<typeof TurnReviewSkill.AnalyzeTurn> = TurnReviewSkill.AnalyzeTurn.pipe(
38
+ Operation.withHandler(
39
+ Effect.fnUntraced(
40
+ function* () {
41
+ const settings = Option.getOrUndefined(yield* Capabilities.getAtomValueOption(AssistantCapabilities.Settings));
42
+ const observability = Option.getOrUndefined(
43
+ yield* Capability.getOption(ObservabilityCapabilities.Observability),
44
+ );
45
+ // Both opt-ins are re-read here: the skill stays bound to a chat after the user opts out.
46
+ if (!settings?.reportStruggles || !observability?.enabled) {
47
+ return;
48
+ }
49
+
50
+ const chat = yield* Harness.getChat;
51
+ const spaceId = Obj.getDatabase(chat)?.spaceId;
52
+ if (!spaceId || !contentCaptureAllowed(spaceId)) {
53
+ return;
54
+ }
55
+
56
+ const history = yield* Harness.history;
57
+ if (history.length === 0) {
58
+ return;
59
+ }
60
+
61
+ const { value: verdict } = yield* LanguageModel.generateObject({
62
+ schema: TurnVerdict,
63
+ objectName: 'turn_verdict',
64
+ prompt: Prompt.setSystem(Prompt.make(formatReviewPrompt(history)), REVIEW_SYSTEM_PROMPT),
65
+ }).pipe(Effect.provide(AiService.languageModel(REVIEW_MODEL)));
66
+ log.info('turn reviewed', { chat: chat.id, struggled: verdict.struggled, cause: verdict.cause });
67
+ if (!isReportable(verdict)) {
68
+ return;
69
+ }
70
+
71
+ const binder = yield* Harness.binder;
72
+ const feed = yield* Database.load(chat.feed);
73
+ const header: TrajectoryHeader = {
74
+ sessionId: Obj.getURI(feed, { prefer: 'absolute' }),
75
+ model: chat.session?.model,
76
+ codeMode: settings.codeMode ?? false,
77
+ skills: binder
78
+ .getSkills()
79
+ .filter((skill) => Obj.getMeta(skill).key !== TurnReviewSkill.key)
80
+ .map((skill) => skill.name),
81
+ verdict,
82
+ };
83
+ const trajectoryKey = yield* Effect.promise(() =>
84
+ observability.support.uploadNdjson(toTrajectoryNdjson(header, history), 'trajectory'),
85
+ );
86
+ observability.events.captureEvent(
87
+ STRUGGLE_EVENT,
88
+ toStruggleEventProperties({ ...header, history, trajectoryKey }),
89
+ );
90
+ },
91
+ Effect.catchCause((cause) => Effect.sync(() => log.warn('turn review failed', { cause }))),
92
+ ),
93
+ ),
94
+ );
95
+
96
+ export default handler;
@@ -8,18 +8,14 @@ import * as Capabilities from '@dxos/app-framework/Capabilities';
8
8
  import * as Capability from '@dxos/app-framework/Capability';
9
9
  import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
10
10
  import { AiContext } from '@dxos/assistant';
11
- import * as AlarmSkill from '@dxos/assistant-toolkit/AlarmSkill';
12
- import * as ChatContextSkill from '@dxos/assistant-toolkit/ChatContextSkill';
13
- import * as PlanningSkill from '@dxos/assistant-toolkit/PlanningSkill';
14
11
  import * as Chat from '@dxos/assistant/Chat';
15
12
  import * as Operation from '@dxos/compute/Operation';
16
- import * as Skill from '@dxos/compute/Skill';
17
13
  import { Database, Feed, Ref } from '@dxos/echo';
18
- import * as DatabaseSkill from '@dxos/plugin-space/DatabaseSkill';
19
14
 
20
- import { AssistantSkill, PluginManagerSkill } from '#skills';
21
15
  import { AssistantOperation } from '#types';
22
16
 
17
+ import { bindChatDefaults, contributesPluginManager } from '../util/default-skills.ts';
18
+
23
19
  const handler: Operation.WithHandler<typeof AssistantOperation.CreateChat> = AssistantOperation.CreateChat.pipe(
24
20
  Operation.withHandler(
25
21
  Effect.fnUntraced(function* ({ name, instructions }) {
@@ -33,39 +29,11 @@ const handler: Operation.WithHandler<typeof AssistantOperation.CreateChat> = Ass
33
29
  const feed = db.add(Feed.make());
34
30
  const chat = Chat.make({ name, feed: Ref.make(feed), instructions });
35
31
 
36
- // Dynamic import to avoid circular dependency with the barrel that also exports SkillManagerHandlers.
37
- const { SkillManagerSkill } = yield* Effect.promise(() => import('@dxos/assistant-toolkit'));
38
-
39
- // Only an extensible host contributes the plugin-manager skill, since its tools resolve to the
40
- // registry plugin's handlers; binding it elsewhere would bind a skill that cannot run.
41
- const contributed = yield* Capability.getAll(AppCapabilities.SkillDefinition);
42
- const pluginManagerContributed = contributed.some(({ key }) => key === PluginManagerSkill.key);
32
+ const pluginManager = contributesPluginManager(yield* Capability.getAll(AppCapabilities.SkillDefinition));
43
33
 
44
34
  const runtime = yield* Effect.context<Database.Service>();
45
35
  const binder = new AiContext.Binder({ feed, runtime, registry });
46
-
47
- // Bind default skills via registry refs — no DB clone needed since the ECHO ref
48
- // resolver already spans the hypergraph registry.
49
- yield* Effect.promise(() =>
50
- binder.use((b: AiContext.Binder) =>
51
- b.bind({
52
- skills: [
53
- AssistantSkill,
54
- DatabaseSkill,
55
- ChatContextSkill,
56
- SkillManagerSkill,
57
- AlarmSkill,
58
- // Bound by default rather than agent-enabled: the conversation's checklist is durable
59
- // and invisible to the model, so a chat without this skill cannot read or update the
60
- // tasks it is already carrying — and a model that enables it mid-turn has already
61
- // answered a task question from nothing.
62
- PlanningSkill,
63
- ...(pluginManagerContributed ? [PluginManagerSkill] : []),
64
- ].map(({ key }) => Ref.fromURI(Skill.registryURI(key))),
65
- objects: [Ref.make(chat)],
66
- }),
67
- ),
68
- );
36
+ yield* Effect.promise(() => binder.use((b: AiContext.Binder) => bindChatDefaults(b, { chat, pluginManager })));
69
37
 
70
38
  return { object: chat };
71
39
  }),
@@ -122,11 +122,10 @@ describe('GenerateHomeSuggestions', () => {
122
122
  );
123
123
 
124
124
  it.effect(
125
- 'a changed set reuses cached prompts within the refresh interval',
125
+ 'cached prompts are reused within the refresh interval whatever the recent set is',
126
126
  Effect.fnUntraced(
127
127
  function* (_) {
128
128
  const { db } = yield* Database.Service;
129
- yield* addNotes(5);
130
129
  const stored = ['Refine the proposal', 'Review the schedule', 'Draft a summary'];
131
130
  testRegistry.set(testCacheAtom, {
132
131
  [db.spaceId]: { generatedAt: Date.now() - HOUR_MS / 2, prompts: stored, fingerprint: 'another set' },
@@ -3,6 +3,7 @@
3
3
  //
4
4
 
5
5
  import * as LanguageModel from 'effect/ai/LanguageModel';
6
+ import * as Array from 'effect/Array';
6
7
  import * as Effect from 'effect/Effect';
7
8
  import * as Schema from 'effect/Schema';
8
9
 
@@ -44,14 +45,24 @@ const handler: Operation.WithHandler<typeof AssistantOperation.GenerateHomeSugge
44
45
  const { db } = yield* Database.Service;
45
46
  const spaceId = db.spaceId;
46
47
 
48
+ const cache = yield* Capabilities.getAtomValue(AssistantCapabilities.HomeSuggestionsCache);
49
+ const entry = cache[spaceId];
50
+ const age = entry ? Date.now() - entry.generatedAt : Infinity;
51
+ if (entry && age < MIN_REFRESH_MS) {
52
+ return { prompts: [...entry.prompts] };
53
+ }
54
+
47
55
  // Build the recent-objects filter (mirrors SpaceHomeRecent).
48
56
  const schemas = yield* Capability.getAll(AppCapabilities.Schema);
49
57
  const collectionTypename = Type.getTypename(Collection.Collection);
50
- const types = schemas
51
- .flat()
52
- .filter(Type.isType)
53
- .filter((type) => TypeOptions.isUserType(type))
54
- .filter((type) => Type.getTypename(type) !== collectionTypename);
58
+ const types = Array.dedupeWith(
59
+ schemas
60
+ .flat()
61
+ .filter(Type.isType)
62
+ .filter((type) => TypeOptions.isUserType(type))
63
+ .filter((type) => Type.getTypename(type) !== collectionTypename),
64
+ (a, b) => Type.getURI(a) === Type.getURI(b),
65
+ );
55
66
  if (types.length === 0) {
56
67
  return { prompts: [] };
57
68
  }
@@ -85,10 +96,7 @@ const handler: Operation.WithHandler<typeof AssistantOperation.GenerateHomeSugge
85
96
  .map(({ label, typename }) => `${typename}:${label}`)
86
97
  .sort()
87
98
  .join('\n');
88
- const cache = yield* Capabilities.getAtomValue(AssistantCapabilities.HomeSuggestionsCache);
89
- const entry = cache[spaceId];
90
- const age = entry ? Date.now() - entry.generatedAt : Infinity;
91
- if (entry && (age < MIN_REFRESH_MS || (entry.fingerprint === fingerprint && age < CACHE_TTL_MS))) {
99
+ if (entry && entry.fingerprint === fingerprint && age < CACHE_TTL_MS) {
92
100
  return { prompts: [...entry.prompts] };
93
101
  }
94
102
 
@@ -6,6 +6,7 @@ import * as Operation from '@dxos/compute/Operation';
6
6
  import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
7
7
  import * as RoutineOperation from '@dxos/plugin-routine/RoutineOperation';
8
8
 
9
+ import { TurnReviewSkill } from '#skills';
9
10
  import { AssistantOperation } from '#types';
10
11
 
11
12
  export const AssistantOperationHandlerSet = OperationHandlerSet.lazy([
@@ -23,4 +24,5 @@ export const AssistantOperationHandlerSet = OperationHandlerSet.lazy([
23
24
  AssistantOperation.SetCurrentChat.pipe(Operation.lazyHandler(() => import('./set-current-chat.ts'))),
24
25
  AssistantOperation.SetTracePanelDebug.pipe(Operation.lazyHandler(() => import('./set-trace-panel-debug.ts'))),
25
26
  AssistantOperation.UpdateChatName.pipe(Operation.lazyHandler(() => import('./update-chat-name.ts'))),
27
+ TurnReviewSkill.AnalyzeTurn.pipe(Operation.lazyHandler(() => import('./analyze-turn.ts'))),
26
28
  ]);
@@ -8,6 +8,7 @@ import * as Option from 'effect/Option';
8
8
  import * as Capabilities from '@dxos/app-framework/Capabilities';
9
9
  import * as Capability from '@dxos/app-framework/Capability';
10
10
  import * as Plugin from '@dxos/app-framework/Plugin';
11
+ import * as Agent from '@dxos/assistant/Agent';
11
12
  import * as Chat from '@dxos/assistant/Chat';
12
13
  import * as AgentService from '@dxos/compute/AgentService';
13
14
  import * as Operation from '@dxos/compute/Operation';
@@ -17,8 +18,8 @@ import { ContentBlock } from '@dxos/types';
17
18
 
18
19
  import { AssistantCapabilities, AssistantEvents, AssistantOperation } from '#types';
19
20
 
21
+ import { defaultPreset, providerForModel } from '../chat-model/index.ts';
20
22
  import { ChatNotSpecifiedError } from '../errors.ts';
21
- import { defaultPreset, providerForModel } from '../processor/index.ts';
22
23
 
23
24
  const handler: Operation.WithHandler<typeof AssistantOperation.RunPromptInChat> =
24
25
  AssistantOperation.RunPromptInChat.pipe(
@@ -62,7 +63,7 @@ const handler: Operation.WithHandler<typeof AssistantOperation.RunPromptInChat>
62
63
  const model = chat.session?.model ?? preset?.model;
63
64
  const session = yield* AgentService.getSession(chat, {
64
65
  provider: model ? providerForModel(model, preset?.provider) : preset?.provider,
65
- location: chat.remote ? 'edge' : 'local',
66
+ location: Agent.chatLocation(chat),
66
67
  });
67
68
  // A plain string is submitted as-is so the default path keeps its existing shape; a stated
68
69
  // disposition needs the block form, which is the only place it can be carried.
@@ -0,0 +1,92 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { Message } from '@dxos/types';
8
+
9
+ import {
10
+ type TurnVerdict,
11
+ causeGroup,
12
+ formatReviewPrompt,
13
+ isReportable,
14
+ splitTurn,
15
+ toStruggleEventProperties,
16
+ toTrajectoryNdjson,
17
+ } from './turn-review.ts';
18
+
19
+ const text = (created: string, sender: 'user' | 'assistant', value: string) =>
20
+ Message.make({ created, sender, blocks: [{ _tag: 'text', text: value }] });
21
+
22
+ const HISTORY = [
23
+ text('2026-10-06T09:00:00.000Z', 'user', 'earlier question'),
24
+ text('2026-10-06T09:00:05.000Z', 'assistant', 'earlier answer'),
25
+ text('2026-10-06T10:00:00.000Z', 'user', 'latest question'),
26
+ Message.make({
27
+ created: '2026-10-06T10:00:02.000Z',
28
+ sender: 'assistant',
29
+ blocks: [
30
+ { _tag: 'toolCall', toolCallId: '1', name: 'search', input: '{}', providerExecuted: false },
31
+ { _tag: 'toolResult', toolCallId: '1', name: 'search', error: 'boom', providerExecuted: false },
32
+ ],
33
+ }),
34
+ ];
35
+
36
+ const VERDICT: TurnVerdict = {
37
+ struggled: true,
38
+ cause: 'system_prompt_missing',
39
+ severity: 'high',
40
+ summary: 'The agent did not know how to find notes.',
41
+ tools: [],
42
+ };
43
+
44
+ describe('turn review', () => {
45
+ test('the turn starts at the last user message', ({ expect }) => {
46
+ const { context, turn } = splitTurn(HISTORY);
47
+ expect(context).toHaveLength(2);
48
+ expect(turn).toHaveLength(2);
49
+ });
50
+
51
+ test('only prompting and tooling causes are reported', ({ expect }) => {
52
+ expect(causeGroup('system_prompt_wrong')).toBe('system_prompt');
53
+ expect(causeGroup('tool_missing')).toBe('tooling');
54
+ expect(isReportable(VERDICT)).toBe(true);
55
+ expect(isReportable({ ...VERDICT, cause: 'model' })).toBe(false);
56
+ expect(isReportable({ ...VERDICT, struggled: false })).toBe(false);
57
+ });
58
+
59
+ test('the prompt marks the latest turn', ({ expect }) => {
60
+ const [before, after] = formatReviewPrompt(HISTORY).split('<latest_turn>');
61
+ expect(before).toContain('earlier question');
62
+ expect(after).toContain('latest question');
63
+ expect(after).toContain('[tool error search] boom');
64
+ });
65
+
66
+ test('trajectory and event carry the turn metadata', ({ expect }) => {
67
+ const header = {
68
+ sessionId: 'echo://space/feed',
69
+ model: 'dxn:com.anthropic.model.claude-sonnet-5.default',
70
+ codeMode: false,
71
+ skills: ['Markdown', 'Tables'],
72
+ verdict: VERDICT,
73
+ };
74
+ const lines = toTrajectoryNdjson(header, HISTORY)
75
+ .split('\n')
76
+ .map((line) => JSON.parse(line));
77
+ expect(lines).toHaveLength(1 + HISTORY.length);
78
+ expect(lines[0]).toMatchObject({ type: 'header', skills: ['Markdown', 'Tables'] });
79
+
80
+ expect(toStruggleEventProperties({ ...header, history: HISTORY, trajectoryKey: 'k' })).toMatchObject({
81
+ $ai_session_id: 'echo://space/feed',
82
+ model: 'dxn:com.anthropic.model.claude-sonnet-5.default',
83
+ code_mode: false,
84
+ skills: 'Markdown,Tables',
85
+ cause_group: 'system_prompt',
86
+ turn_message_count: 2,
87
+ turn_tool_call_count: 1,
88
+ turn_tool_error_count: 1,
89
+ trajectory_key: 'k',
90
+ });
91
+ });
92
+ });