@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.
- package/PLUGIN.mdl +25 -0
- package/dist/lib/AssistantCapabilities.mjs +8 -1
- package/dist/lib/AssistantCapabilities.mjs.map +1 -1
- package/dist/lib/Settings.mjs +4 -0
- package/dist/lib/Settings.mjs.map +1 -1
- package/dist/lib/TurnReviewSkill.mjs +55 -0
- package/dist/lib/TurnReviewSkill.mjs.map +1 -0
- package/dist/lib/chunk-AgentArticle.mjs +1 -1
- package/dist/lib/chunk-AssistantSettings.mjs +1 -1
- package/dist/lib/chunk-AssistantSettings.mjs.map +1 -1
- package/dist/lib/chunk-Chat.mjs +322 -174
- package/dist/lib/chunk-Chat.mjs.map +1 -1
- package/dist/lib/chunk-ChatArticle.mjs +14 -7
- package/dist/lib/chunk-ChatArticle.mjs.map +1 -1
- package/dist/lib/chunk-ChatDialog.mjs +5 -4
- package/dist/lib/chunk-ChatDialog.mjs.map +1 -1
- package/dist/lib/chunk-IntegrationPrompt.mjs +1 -1
- package/dist/lib/chunk-PLUGIN.mjs +1 -1
- package/dist/lib/chunk-PluginUrlPrompt.mjs +1 -1
- package/dist/lib/chunk-QuestionSurface.mjs +1 -1
- package/dist/lib/chunk-SpaceHomePrompt.mjs +116 -60
- package/dist/lib/chunk-SpaceHomePrompt.mjs.map +1 -1
- package/dist/lib/chunk-TriggerStatus.mjs +1 -1
- package/dist/lib/chunk-agent-service.mjs +3 -1
- package/dist/lib/chunk-agent-service.mjs.map +1 -1
- package/dist/lib/chunk-analyze-turn.mjs +220 -0
- package/dist/lib/chunk-analyze-turn.mjs.map +1 -0
- package/dist/lib/{chunk-processor.mjs → chunk-chat-model.mjs} +95 -43
- package/dist/lib/chunk-chat-model.mjs.map +1 -0
- package/dist/lib/chunk-context.mjs.map +1 -1
- package/dist/lib/chunk-create-chat.mjs +5 -19
- package/dist/lib/chunk-create-chat.mjs.map +1 -1
- package/dist/lib/chunk-create-object.mjs +1 -1
- package/dist/lib/chunk-default-skills.mjs +28 -0
- package/dist/lib/chunk-default-skills.mjs.map +1 -0
- package/dist/lib/chunk-generate-home-suggestions.mjs +8 -6
- package/dist/lib/chunk-generate-home-suggestions.mjs.map +1 -1
- package/dist/lib/chunk-presets.mjs +1 -1
- package/dist/lib/chunk-presets.mjs.map +1 -1
- package/dist/lib/chunk-question-resumer.mjs +1 -1
- package/dist/lib/chunk-react-surface.mjs +1 -1
- package/dist/lib/chunk-run-prompt-in-chat.mjs +2 -1
- package/dist/lib/chunk-run-prompt-in-chat.mjs.map +1 -1
- package/dist/lib/chunk-run-prompt-in-new-chat.mjs +1 -1
- package/dist/lib/chunk-schema-defs.mjs +2 -2
- package/dist/lib/chunk-skill-definition.mjs +3 -2
- package/dist/lib/chunk-skill-definition.mjs.map +1 -1
- package/dist/lib/components.mjs +13 -13
- package/dist/lib/components.mjs.map +1 -1
- package/dist/lib/hooks.mjs +40 -31
- package/dist/lib/hooks.mjs.map +1 -1
- package/dist/lib/operations.mjs +3 -1
- package/dist/lib/operations.mjs.map +1 -1
- package/dist/lib/skills.mjs +2 -1
- package/dist/lib/translations.mjs +4 -2
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/Chat.d.ts +1 -1
- package/dist/types/src/capabilities/agent-service.d.ts.map +1 -1
- package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -1
- package/dist/types/src/{processor/processor.d.ts → chat-model/chat-model.d.ts} +24 -22
- package/dist/types/src/chat-model/chat-model.d.ts.map +1 -0
- package/dist/types/src/chat-model/chat-model.edge.test.d.ts +2 -0
- package/dist/types/src/chat-model/chat-model.edge.test.d.ts.map +1 -0
- package/dist/types/src/chat-model/chat-model.node.test.d.ts +2 -0
- package/dist/types/src/chat-model/chat-model.node.test.d.ts.map +1 -0
- package/dist/types/src/chat-model/chat-model.scripted.node.test.d.ts +2 -0
- package/dist/types/src/chat-model/chat-model.scripted.node.test.d.ts.map +1 -0
- package/dist/types/src/{processor → chat-model}/index.d.ts +1 -1
- package/dist/types/src/chat-model/index.d.ts.map +1 -0
- package/dist/types/src/chat-model/outbox.d.ts.map +1 -0
- package/dist/types/src/chat-model/outbox.test.d.ts.map +1 -0
- package/dist/types/src/chat-model/presets.d.ts.map +1 -0
- package/dist/types/src/chat-model/presets.test.d.ts.map +1 -0
- package/dist/types/src/{processor → chat-model}/prompt.d.ts +2 -2
- package/dist/types/src/chat-model/prompt.d.ts.map +1 -0
- package/dist/types/src/chat-model/prompt.test.d.ts.map +1 -0
- package/dist/types/src/chat-model/streaming.node.test.d.ts.map +1 -0
- package/dist/types/src/chat-model/testing/chat-model-recorder.d.ts +58 -0
- package/dist/types/src/chat-model/testing/chat-model-recorder.d.ts.map +1 -0
- package/dist/types/src/chat-model/testing/harness.d.ts +22 -0
- package/dist/types/src/chat-model/testing/harness.d.ts.map +1 -0
- package/dist/types/src/chat-model/thread.d.ts.map +1 -0
- package/dist/types/src/chat-model/thread.test.d.ts.map +1 -0
- package/dist/types/src/components/Chat/Chat.d.ts +6 -6
- package/dist/types/src/components/Chat/Chat.d.ts.map +1 -1
- package/dist/types/src/components/Chat/context.d.ts +5 -3
- package/dist/types/src/components/Chat/context.d.ts.map +1 -1
- package/dist/types/src/components/ChatPrompt/ChatActions.d.ts +1 -1
- package/dist/types/src/components/ChatPrompt/ChatActions.d.ts.map +1 -1
- package/dist/types/src/components/ChatPrompt/ChatMcpErrors.d.ts +3 -3
- package/dist/types/src/components/ChatPrompt/ChatMcpErrors.d.ts.map +1 -1
- package/dist/types/src/components/ChatPrompt/ChatOptions.d.ts +3 -1
- package/dist/types/src/components/ChatPrompt/ChatOptions.d.ts.map +1 -1
- package/dist/types/src/components/ChatPrompt/ChatOptions.stories.d.ts +4 -3
- package/dist/types/src/components/ChatPrompt/ChatOptions.stories.d.ts.map +1 -1
- package/dist/types/src/components/ChatPrompt/ChatPrompt.d.ts +15 -5
- package/dist/types/src/components/ChatPrompt/ChatPrompt.d.ts.map +1 -1
- package/dist/types/src/components/ChatPrompt/ChatPrompt.stories.d.ts +1 -1
- package/dist/types/src/components/ChatPrompt/ChatStatus.d.ts +1 -1
- package/dist/types/src/components/ChatPrompt/ChatStatus.d.ts.map +1 -1
- package/dist/types/src/components/ConnectorAuthMenu/ConnectorAuthMenu.stories.d.ts +9 -0
- package/dist/types/src/components/ConnectorAuthMenu/ConnectorAuthMenu.stories.d.ts.map +1 -1
- package/dist/types/src/components/Toolbox/Toolbox.d.ts +3 -3
- package/dist/types/src/components/Toolbox/Toolbox.d.ts.map +1 -1
- package/dist/types/src/containers/ChatArticle/ChatArticle.d.ts.map +1 -1
- package/dist/types/src/containers/ChatArticle/ChatArticle.stories.d.ts +1 -1
- package/dist/types/src/containers/SpaceHomePrompt/SpaceHomePrompt.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +2 -2
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/{useChatProcessor.d.ts → useChatModel.d.ts} +6 -6
- package/dist/types/src/hooks/useChatModel.d.ts.map +1 -0
- package/dist/types/src/hooks/useDebug.d.ts +3 -3
- package/dist/types/src/hooks/useDebug.d.ts.map +1 -1
- package/dist/types/src/hooks/useHomeSuggestions.d.ts +3 -3
- package/dist/types/src/hooks/useHomeSuggestions.d.ts.map +1 -1
- package/dist/types/src/hooks/usePresets.d.ts +1 -1
- package/dist/types/src/hooks/usePresets.d.ts.map +1 -1
- package/dist/types/src/hooks/useSelectionContext.d.ts +3 -3
- package/dist/types/src/hooks/useSelectionContext.d.ts.map +1 -1
- package/dist/types/src/operations/analyze-turn.d.ts +5 -0
- package/dist/types/src/operations/analyze-turn.d.ts.map +1 -0
- package/dist/types/src/operations/analyze-turn.test.d.ts +2 -0
- package/dist/types/src/operations/analyze-turn.test.d.ts.map +1 -0
- package/dist/types/src/operations/create-chat.d.ts.map +1 -1
- package/dist/types/src/operations/generate-home-suggestions.d.ts.map +1 -1
- package/dist/types/src/operations/index.d.ts.map +1 -1
- package/dist/types/src/operations/run-prompt-in-chat.d.ts.map +1 -1
- package/dist/types/src/review/turn-review.d.ts +58 -0
- package/dist/types/src/review/turn-review.d.ts.map +1 -0
- package/dist/types/src/review/turn-review.test.d.ts +2 -0
- package/dist/types/src/review/turn-review.test.d.ts.map +1 -0
- package/dist/types/src/skills/index.d.ts +1 -0
- package/dist/types/src/skills/index.d.ts.map +1 -1
- package/dist/types/src/skills/turn-review/TurnReviewSkill.d.ts +21 -0
- package/dist/types/src/skills/turn-review/TurnReviewSkill.d.ts.map +1 -0
- package/dist/types/src/skills/turn-review/index.d.ts +2 -0
- package/dist/types/src/skills/turn-review/index.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/types/Assistant.d.ts +1 -0
- package/dist/types/src/types/Assistant.d.ts.map +1 -1
- package/dist/types/src/types/AssistantCapabilities.d.ts +7 -1
- package/dist/types/src/types/AssistantCapabilities.d.ts.map +1 -1
- package/dist/types/src/types/Settings.d.ts +1 -0
- package/dist/types/src/types/Settings.d.ts.map +1 -1
- package/dist/types/src/util/agent-options.d.ts +27 -0
- package/dist/types/src/util/agent-options.d.ts.map +1 -0
- package/dist/types/src/util/agent-options.test.d.ts +2 -0
- package/dist/types/src/util/agent-options.test.d.ts.map +1 -0
- package/dist/types/src/util/default-skills.d.ts +10 -0
- package/dist/types/src/util/default-skills.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +89 -80
- package/src/capabilities/agent-service.ts +2 -1
- package/src/capabilities/skill-definition.ts +2 -1
- package/src/chat-model/chat-model.edge.test.ts +301 -0
- package/src/{processor/processor.node.test.ts → chat-model/chat-model.node.test.ts} +19 -9
- package/src/chat-model/chat-model.scripted.node.test.ts +441 -0
- package/src/{processor/processor.ts → chat-model/chat-model.ts} +140 -45
- package/src/{processor → chat-model}/index.ts +1 -1
- package/src/{processor → chat-model}/prompt.ts +2 -2
- package/src/{processor → chat-model}/streaming.node.test.ts +108 -116
- package/src/chat-model/testing/chat-model-recorder.ts +249 -0
- package/src/chat-model/testing/harness.ts +81 -0
- package/src/{processor → chat-model}/thread.test.ts +1 -1
- package/src/components/Chat/Chat.tsx +44 -38
- package/src/components/Chat/Error.stories.tsx +1 -1
- package/src/components/Chat/context.ts +5 -3
- package/src/components/ChatPrompt/ChatActions.tsx +1 -1
- package/src/components/ChatPrompt/ChatMcpErrors.tsx +6 -6
- package/src/components/ChatPrompt/ChatOptions.stories.tsx +37 -3
- package/src/components/ChatPrompt/ChatOptions.tsx +118 -10
- package/src/components/ChatPrompt/ChatPrompt.stories.tsx +10 -10
- package/src/components/ChatPrompt/ChatPrompt.tsx +44 -27
- package/src/components/ChatPrompt/ChatStatus.tsx +8 -7
- package/src/components/Toolbox/Toolbox.tsx +12 -13
- package/src/containers/AssistantSettings/AssistantSettings.tsx +1 -1
- package/src/containers/ChatArticle/ChatArticle.stories.tsx +1 -1
- package/src/containers/ChatArticle/ChatArticle.tsx +16 -6
- package/src/containers/ChatDialog/ChatDialog.tsx +4 -4
- package/src/containers/SpaceHomePrompt/SpaceHomePrompt.tsx +110 -57
- package/src/hooks/index.ts +2 -2
- package/src/hooks/{useChatProcessor.ts → useChatModel.ts} +16 -14
- package/src/hooks/useDebug.ts +8 -8
- package/src/hooks/useHomeSuggestions.ts +14 -9
- package/src/hooks/usePresets.ts +2 -2
- package/src/hooks/useProcessEphemeralStatus.ts +1 -1
- package/src/hooks/useSelectionContext.ts +3 -5
- package/src/operations/analyze-turn.test.ts +194 -0
- package/src/operations/analyze-turn.ts +96 -0
- package/src/operations/create-chat.ts +4 -36
- package/src/operations/generate-home-suggestions.test.ts +1 -2
- package/src/operations/generate-home-suggestions.ts +17 -9
- package/src/operations/index.ts +2 -0
- package/src/operations/run-prompt-in-chat.ts +3 -2
- package/src/review/turn-review.test.ts +92 -0
- package/src/review/turn-review.ts +213 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/turn-review/TurnReviewSkill.ts +46 -0
- package/src/skills/turn-review/index.ts +5 -0
- package/src/translations.ts +2 -0
- package/src/types/AssistantCapabilities.ts +8 -1
- package/src/types/Settings.ts +7 -0
- package/src/util/agent-options.test.ts +54 -0
- package/src/util/agent-options.ts +60 -0
- package/src/util/default-skills.ts +35 -0
- package/dist/lib/chunk-processor.mjs.map +0 -1
- package/dist/types/src/hooks/useChatProcessor.d.ts.map +0 -1
- package/dist/types/src/processor/index.d.ts.map +0 -1
- package/dist/types/src/processor/outbox.d.ts.map +0 -1
- package/dist/types/src/processor/outbox.test.d.ts.map +0 -1
- package/dist/types/src/processor/presets.d.ts.map +0 -1
- package/dist/types/src/processor/presets.test.d.ts.map +0 -1
- package/dist/types/src/processor/processor.d.ts.map +0 -1
- package/dist/types/src/processor/processor.node.test.d.ts +0 -2
- package/dist/types/src/processor/processor.node.test.d.ts.map +0 -1
- package/dist/types/src/processor/prompt.d.ts.map +0 -1
- package/dist/types/src/processor/prompt.test.d.ts.map +0 -1
- package/dist/types/src/processor/streaming.node.test.d.ts.map +0 -1
- package/dist/types/src/processor/thread.d.ts.map +0 -1
- package/dist/types/src/processor/thread.test.d.ts.map +0 -1
- /package/dist/types/src/{processor → chat-model}/outbox.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/outbox.test.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/presets.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/presets.test.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/prompt.test.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/streaming.node.test.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/thread.d.ts +0 -0
- /package/dist/types/src/{processor → chat-model}/thread.test.d.ts +0 -0
- /package/src/{processor → chat-model}/outbox.test.ts +0 -0
- /package/src/{processor → chat-model}/outbox.ts +0 -0
- /package/src/{processor → chat-model}/presets.test.ts +0 -0
- /package/src/{processor → chat-model}/presets.ts +0 -0
- /package/src/{processor → chat-model}/prompt.test.ts +0 -0
- /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
|
|
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 {
|
|
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('
|
|
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
|
|
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*
|
|
54
|
+
const spaceLayer = yield* makeStubSpaceLayer(stubAgentService);
|
|
66
55
|
|
|
67
56
|
const observableRegistry = AtomRegistry.make();
|
|
68
|
-
const
|
|
69
|
-
const
|
|
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
|
-
|
|
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(
|
|
89
|
-
observableRegistry.subscribe(
|
|
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(() =>
|
|
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(
|
|
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(
|
|
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(
|
|
115
|
-
expect(observableRegistry.get(
|
|
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
|
|
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
|
|
129
|
+
const idleChatModel = new ChatModel(
|
|
141
130
|
session,
|
|
142
131
|
yield* makeTestRuntime,
|
|
143
132
|
feed,
|
|
144
|
-
yield*
|
|
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(
|
|
152
|
-
|
|
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(
|
|
158
|
-
expect(idleRegistry.get(
|
|
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
|
|
151
|
+
const chatModel = new ChatModel(
|
|
163
152
|
session,
|
|
164
153
|
yield* makeTestRuntime,
|
|
165
154
|
feed,
|
|
166
|
-
yield*
|
|
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(
|
|
176
|
-
observableRegistry.subscribe(
|
|
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(
|
|
183
|
-
yield* whenAtom(observableRegistry,
|
|
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(
|
|
187
|
-
expect(texts(observableRegistry.get(
|
|
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
|
|
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
|
|
202
|
+
const chatModel = new ChatModel(
|
|
214
203
|
session,
|
|
215
204
|
yield* makeTestRuntime,
|
|
216
205
|
feed,
|
|
217
|
-
yield*
|
|
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(
|
|
213
|
+
const unsubscribe = observableRegistry.subscribe(chatModel.messages, () => {}, { immediate: true });
|
|
225
214
|
yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribe()));
|
|
226
215
|
|
|
227
|
-
const dispose =
|
|
228
|
-
yield* whenAtom(observableRegistry,
|
|
229
|
-
expect(texts(observableRegistry.get(
|
|
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(
|
|
236
|
-
expect(observableRegistry.get(
|
|
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
|
|
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*
|
|
258
|
+
const spaceLayer = yield* makeStubSpaceLayer(stubAgentService);
|
|
270
259
|
|
|
271
260
|
const observableRegistry = AtomRegistry.make();
|
|
272
|
-
const
|
|
273
|
-
const
|
|
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
|
-
|
|
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(() =>
|
|
279
|
+
yield* Effect.promise(() => chatModel.request({ message: 'Hello?' }));
|
|
291
280
|
|
|
292
|
-
const error = observableRegistry.get(
|
|
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(
|
|
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
|
|
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
|
|
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
|
+
};
|