@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
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, it } from '@effect/vitest';
|
|
6
|
+
import * as AiError from 'effect/ai/AiError';
|
|
7
|
+
import * as Deferred from 'effect/Deferred';
|
|
8
|
+
import * as Effect from 'effect/Effect';
|
|
9
|
+
import * as AtomRegistry from 'effect/reactivity/AtomRegistry';
|
|
10
|
+
import * as Schema from 'effect/Schema';
|
|
11
|
+
|
|
12
|
+
import { AssistantTestLayer } from '@dxos/agent-runtime/testing';
|
|
13
|
+
import { ScriptedLanguageModel } from '@dxos/ai/testing';
|
|
14
|
+
import { AiSession } from '@dxos/assistant';
|
|
15
|
+
import * as Chat from '@dxos/assistant/Chat';
|
|
16
|
+
import * as AgentService from '@dxos/compute/AgentService';
|
|
17
|
+
import * as Operation from '@dxos/compute/Operation';
|
|
18
|
+
import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
|
|
19
|
+
import * as Skill from '@dxos/compute/Skill';
|
|
20
|
+
import { Database, Feed, Ref } from '@dxos/echo';
|
|
21
|
+
import * as EffectEx from '@dxos/effect/EffectEx';
|
|
22
|
+
import { TestHelpers } from '@dxos/effect/testing';
|
|
23
|
+
import { DXN } from '@dxos/keys';
|
|
24
|
+
import { Message } from '@dxos/types';
|
|
25
|
+
|
|
26
|
+
import { ChatModel } from './chat-model.ts';
|
|
27
|
+
import { ChatModelRecorder } from './testing/chat-model-recorder.ts';
|
|
28
|
+
import { makeSpaceLayer, makeTestRuntime } from './testing/harness.ts';
|
|
29
|
+
|
|
30
|
+
const { text, toolCall, scriptedAiService } = ScriptedLanguageModel;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The chat model end to end against the real agent process and a scripted model: every indicator the UI
|
|
34
|
+
* binds to is recorded as an event log, and the log is asserted on rather than the end state alone, so a
|
|
35
|
+
* flicker, a missing phase, or a turn that reads idle while the agent works is a test failure.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
//
|
|
39
|
+
// Gate: a tool the test holds open, so a turn can be observed (and interrupted) mid-flight.
|
|
40
|
+
//
|
|
41
|
+
|
|
42
|
+
const Gate = Operation.make({
|
|
43
|
+
meta: {
|
|
44
|
+
key: DXN.make('com.example.operation.chatModelGate'),
|
|
45
|
+
name: 'Gate',
|
|
46
|
+
description: 'Runs until the test releases it.',
|
|
47
|
+
},
|
|
48
|
+
input: Schema.Struct({ label: Schema.String.annotate({ description: 'Label echoed back' }) }),
|
|
49
|
+
output: Schema.String,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Per-test gate state; the handler is module-level, so the test installs a fresh gate before each run. */
|
|
53
|
+
let gate: { started: Deferred.Deferred<void>; release: Deferred.Deferred<void> } | undefined;
|
|
54
|
+
|
|
55
|
+
const makeGate = Effect.gen(function* () {
|
|
56
|
+
const next = { started: yield* Deferred.make<void>(), release: yield* Deferred.make<void>() };
|
|
57
|
+
gate = next;
|
|
58
|
+
return next;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const GateHandlers = OperationHandlerSet.make(
|
|
62
|
+
Gate.pipe(
|
|
63
|
+
Operation.withHandler(
|
|
64
|
+
Effect.fnUntraced(function* ({ label }) {
|
|
65
|
+
const current = gate;
|
|
66
|
+
if (!current) {
|
|
67
|
+
return yield* Effect.die(new Error('Gate not installed.'));
|
|
68
|
+
}
|
|
69
|
+
yield* Deferred.succeed(current.started, undefined);
|
|
70
|
+
yield* Deferred.await(current.release);
|
|
71
|
+
return `${label}: released`;
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const GateSkill = Skill.make({
|
|
78
|
+
key: 'org.dxos.skill.chatModelGate',
|
|
79
|
+
name: 'Gate',
|
|
80
|
+
tools: Skill.toolDefinitions({ operations: [Gate] }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const gateCall = { parts: [toolCall(Operation.toolName(Gate), { label: 'gate' })] };
|
|
84
|
+
|
|
85
|
+
const testLayer = (script: ScriptedLanguageModel.Script) =>
|
|
86
|
+
AssistantTestLayer({
|
|
87
|
+
types: [Chat.Chat, Feed.Feed, Message.Message],
|
|
88
|
+
operationHandlers: [GateHandlers],
|
|
89
|
+
skills: [GateSkill],
|
|
90
|
+
aiService: scriptedAiService(script),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** A provider rejection the agent does not retry, carrying the text the chat should surface. */
|
|
94
|
+
const providerFailure = (description: string) => ({
|
|
95
|
+
fail: new AiError.AiError({
|
|
96
|
+
module: 'ScriptedLanguageModel',
|
|
97
|
+
method: 'streamText',
|
|
98
|
+
reason: new AiError.InvalidRequestError({ description }),
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A chat model over the test layer's real agent service, with a recorder attached before anything runs.
|
|
104
|
+
* The chat is named so the first prompt does not schedule a rename, which would draw on the script.
|
|
105
|
+
*/
|
|
106
|
+
const setup = Effect.fn(function* ({ skills = [] }: { skills?: Skill.Skill[] } = {}) {
|
|
107
|
+
const feed = yield* Database.add(Feed.make());
|
|
108
|
+
const chat = yield* Database.add(Chat.make({ name: 'Test', feed: Ref.make(feed) }));
|
|
109
|
+
const runtime = yield* Effect.context<Database.Service>();
|
|
110
|
+
const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
|
|
111
|
+
if (skills.length > 0) {
|
|
112
|
+
yield* Effect.promise(() => session.context.bind({ skills: skills.map(Skill.makeRef), objects: [] }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The UI reads the app-wide registry, which is also where the agent process writes its status.
|
|
116
|
+
const registry = yield* AtomRegistry.AtomRegistry;
|
|
117
|
+
const chatModel = new ChatModel(session, yield* makeTestRuntime, feed, yield* makeSpaceLayer, {
|
|
118
|
+
chat: Ref.make(chat),
|
|
119
|
+
observableRegistry: registry,
|
|
120
|
+
});
|
|
121
|
+
// Same options the chat model resolves with, so this is the session it drives rather than a rival.
|
|
122
|
+
const agent = yield* AgentService.getSession(chat, { location: 'local' });
|
|
123
|
+
const recorder = new ChatModelRecorder(registry, chatModel, { agentRunning: agent.running });
|
|
124
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => recorder.dispose()));
|
|
125
|
+
return { chat, feed, chatModel, recorder };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const awaitPromise = <T>(promise: () => Promise<T>) => Effect.promise(promise);
|
|
129
|
+
|
|
130
|
+
/** The indicator log without the agent's own signal or the thread, which most assertions read apart. */
|
|
131
|
+
const INDICATORS = ['active', 'streaming', 'activity', 'error'] as const;
|
|
132
|
+
|
|
133
|
+
/** Collapses consecutive duplicates, so a phase repeated across steps reads once. */
|
|
134
|
+
const dedupe = (values: readonly string[]) => values.filter((value, index) => value !== values[index - 1]);
|
|
135
|
+
|
|
136
|
+
describe('ChatModel (scripted agent)', { timeout: 30_000 }, () => {
|
|
137
|
+
it.live(
|
|
138
|
+
'a text reply moves every indicator through one turn and back to idle',
|
|
139
|
+
Effect.fn(
|
|
140
|
+
function* ({ expect }) {
|
|
141
|
+
const { chatModel, recorder } = yield* setup();
|
|
142
|
+
chatModel.send({ message: 'Hello?' });
|
|
143
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
144
|
+
|
|
145
|
+
expect(recorder.violations).toEqual([]);
|
|
146
|
+
expect(recorder.values('active')).toEqual(['false', 'true', 'false']);
|
|
147
|
+
expect(recorder.values('error')).toEqual(['-']);
|
|
148
|
+
|
|
149
|
+
// `starting` is set locally before the process can report anything; the agent's own setup
|
|
150
|
+
// phases follow, then content arriving moves the line to `generating` until the turn settles.
|
|
151
|
+
const phases = recorder.values('activity');
|
|
152
|
+
expect(phases[0]).toBe('-');
|
|
153
|
+
expect(phases[1]).toBe('starting');
|
|
154
|
+
expect(phases).toContain('contacting-provider');
|
|
155
|
+
expect(phases.at(-2)).toBe('generating');
|
|
156
|
+
expect(phases.at(-1)).toBe('-');
|
|
157
|
+
|
|
158
|
+
// The reply streamed and was then committed: the thread ends as the prompt and its answer.
|
|
159
|
+
expect(recorder.values('streaming')).toContain('true');
|
|
160
|
+
expect(recorder.state.streaming).toBe('false');
|
|
161
|
+
yield* awaitPromise(() =>
|
|
162
|
+
recorder.until((state) => state.thread === 'user[read]:"Hello?" | assistant:"Hello world."'),
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
Effect.provide(testLayer([{ delay: '50 millis', parts: [text('Hello world.')] }])),
|
|
166
|
+
TestHelpers.provideTestContext,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
it.live(
|
|
171
|
+
'a prompt shows in the thread at once and is read before the reply lands',
|
|
172
|
+
Effect.fn(
|
|
173
|
+
function* ({ expect }) {
|
|
174
|
+
const { chatModel, recorder } = yield* setup();
|
|
175
|
+
chatModel.send({ message: 'Hello?' });
|
|
176
|
+
// Same frame as the send: the row exists before anything was persisted or dispatched.
|
|
177
|
+
expect(recorder.state.thread).toBe('user[sent]:"Hello?"');
|
|
178
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
179
|
+
|
|
180
|
+
const statuses = dedupe(
|
|
181
|
+
recorder.values('thread').flatMap((thread) => thread.match(/^user\[([a-z]+)\]/)?.[1] ?? []),
|
|
182
|
+
);
|
|
183
|
+
// The row is one row for its whole life, ticking from sent to read once the agent takes it up.
|
|
184
|
+
expect(statuses[0]).toBe('sent');
|
|
185
|
+
expect(statuses.at(-1)).toBe('read');
|
|
186
|
+
expect(recorder.state.thread).toBe('user[read]:"Hello?" | assistant:"Hi."');
|
|
187
|
+
expect(recorder.violations).toEqual([]);
|
|
188
|
+
},
|
|
189
|
+
Effect.provide(testLayer([{ delay: '50 millis', parts: [text('Hi.')] }])),
|
|
190
|
+
TestHelpers.provideTestContext,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
it.live(
|
|
195
|
+
'a turn parked in a tool keeps every indicator up until the tool returns',
|
|
196
|
+
Effect.fn(
|
|
197
|
+
function* ({ expect }) {
|
|
198
|
+
const { chatModel, recorder } = yield* setup({ skills: [GateSkill] });
|
|
199
|
+
const { started, release } = yield* makeGate;
|
|
200
|
+
chatModel.send({ message: 'Use the gate.' });
|
|
201
|
+
yield* Deferred.await(started);
|
|
202
|
+
|
|
203
|
+
// Nothing is streaming while the tool runs; the line names the tool, and the turn is still active.
|
|
204
|
+
yield* awaitPromise(() => recorder.until((state) => state.activity.startsWith('calling-tool')));
|
|
205
|
+
expect(recorder.state.active).toBe('true');
|
|
206
|
+
expect(recorder.state.agent).toBe('running');
|
|
207
|
+
|
|
208
|
+
yield* Deferred.succeed(release, undefined);
|
|
209
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
210
|
+
|
|
211
|
+
expect(recorder.violations).toEqual([]);
|
|
212
|
+
expect(recorder.values('active')).toEqual(['false', 'true', 'false']);
|
|
213
|
+
const phases = dedupe(recorder.values('activity').map((phase) => phase.replace(/\(.*\)/, '')));
|
|
214
|
+
expect(phases.indexOf('calling-tool')).toBeGreaterThan(phases.indexOf('starting'));
|
|
215
|
+
// The model is contacted again with the tool's result before the turn ends.
|
|
216
|
+
expect(phases.lastIndexOf('contacting-provider')).toBeGreaterThan(phases.indexOf('calling-tool'));
|
|
217
|
+
expect(recorder.state.thread).toContain('assistant:"Gate released."');
|
|
218
|
+
},
|
|
219
|
+
Effect.provide(testLayer([gateCall, { parts: [text('Gate released.')] }])),
|
|
220
|
+
TestHelpers.provideTestContext,
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
it.live(
|
|
225
|
+
'cancelling mid-tool clears every indicator without reporting an error',
|
|
226
|
+
Effect.fn(
|
|
227
|
+
function* ({ expect }) {
|
|
228
|
+
const { chatModel, recorder } = yield* setup({ skills: [GateSkill] });
|
|
229
|
+
const { started } = yield* makeGate;
|
|
230
|
+
chatModel.send({ message: 'Use the gate.' });
|
|
231
|
+
yield* Deferred.await(started);
|
|
232
|
+
yield* awaitPromise(() => recorder.until((state) => state.activity.startsWith('calling-tool')));
|
|
233
|
+
|
|
234
|
+
yield* awaitPromise(() => chatModel.cancel());
|
|
235
|
+
yield* awaitPromise(() => recorder.until((state) => state.agent !== 'running'));
|
|
236
|
+
|
|
237
|
+
expect(recorder.state.active).toBe('false');
|
|
238
|
+
expect(recorder.state.activity).toBe('-');
|
|
239
|
+
expect(recorder.state.streaming).toBe('false');
|
|
240
|
+
// A cancel is the reader's own doing, not a failure to report.
|
|
241
|
+
expect(recorder.values('error')).toEqual(['-']);
|
|
242
|
+
expect(recorder.violations).toEqual([]);
|
|
243
|
+
},
|
|
244
|
+
Effect.provide(testLayer([gateCall, { parts: [text('unreachable')] }])),
|
|
245
|
+
TestHelpers.provideTestContext,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
it.live(
|
|
250
|
+
'a provider failure surfaces its message and clears the working indicators',
|
|
251
|
+
Effect.fn(
|
|
252
|
+
function* ({ expect }) {
|
|
253
|
+
const { chatModel, recorder } = yield* setup();
|
|
254
|
+
chatModel.send({ message: 'Hello?' });
|
|
255
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
256
|
+
yield* awaitPromise(() => recorder.until((state) => state.error !== '-'));
|
|
257
|
+
|
|
258
|
+
expect(recorder.state.error).toBe('Invalid request. model rejected the request');
|
|
259
|
+
expect(recorder.state.active).toBe('false');
|
|
260
|
+
expect(recorder.state.activity).toBe('-');
|
|
261
|
+
expect(recorder.state.streaming).toBe('false');
|
|
262
|
+
// The error is raised once the turn has ended, never while the turn still reads as working.
|
|
263
|
+
expect(recorder.firstAt('error', (value) => value !== '-')).toBeGreaterThanOrEqual(
|
|
264
|
+
recorder.firstAt('active', 'true') ?? 0,
|
|
265
|
+
);
|
|
266
|
+
expect(recorder.violations).toEqual([]);
|
|
267
|
+
},
|
|
268
|
+
Effect.provide(testLayer([providerFailure('model rejected the request')])),
|
|
269
|
+
TestHelpers.provideTestContext,
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
it.live(
|
|
274
|
+
'a retry after a failure clears the error and runs a fresh turn',
|
|
275
|
+
Effect.fn(
|
|
276
|
+
function* ({ expect }) {
|
|
277
|
+
const { chatModel, recorder } = yield* setup();
|
|
278
|
+
chatModel.send({ message: 'Hello?' });
|
|
279
|
+
yield* awaitPromise(() => recorder.until((state) => state.error !== '-' && state.active === 'false'));
|
|
280
|
+
|
|
281
|
+
yield* awaitPromise(() => chatModel.retry());
|
|
282
|
+
yield* awaitPromise(() => recorder.until((state) => state.active === 'false' && state.error === '-'));
|
|
283
|
+
|
|
284
|
+
expect(dedupe(recorder.values('active'))).toEqual(['false', 'true', 'false', 'true', 'false']);
|
|
285
|
+
expect(recorder.values('error')).toEqual(['-', 'Invalid request. transient', '-']);
|
|
286
|
+
expect(recorder.state.thread).toContain('assistant:"Recovered."');
|
|
287
|
+
expect(recorder.violations).toEqual([]);
|
|
288
|
+
},
|
|
289
|
+
Effect.provide(testLayer([providerFailure('transient'), { parts: [text('Recovered.')] }])),
|
|
290
|
+
TestHelpers.provideTestContext,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
it.live(
|
|
295
|
+
'a prompt sent mid-turn queues behind it and the chat stays active across both turns',
|
|
296
|
+
Effect.fn(
|
|
297
|
+
function* ({ expect }) {
|
|
298
|
+
const { chatModel, recorder } = yield* setup({ skills: [GateSkill] });
|
|
299
|
+
const { started, release } = yield* makeGate;
|
|
300
|
+
chatModel.send({ message: 'First.' });
|
|
301
|
+
yield* Deferred.await(started);
|
|
302
|
+
|
|
303
|
+
chatModel.send({ message: 'Second.' });
|
|
304
|
+
// Queued, not dispatched: the running turn is left alone.
|
|
305
|
+
yield* awaitPromise(() => recorder.until((state) => /user\[[a-z]+\]:"Second\."/.test(state.thread)));
|
|
306
|
+
expect(recorder.state.active).toBe('true');
|
|
307
|
+
|
|
308
|
+
yield* Deferred.succeed(release, undefined);
|
|
309
|
+
yield* awaitPromise(() => recorder.until((state) => state.active === 'false'));
|
|
310
|
+
|
|
311
|
+
// One continuous active span: the queued turn runs inside the first request's wait.
|
|
312
|
+
expect(recorder.values('active')).toEqual(['false', 'true', 'false']);
|
|
313
|
+
expect(recorder.violations).toEqual([]);
|
|
314
|
+
yield* awaitPromise(() =>
|
|
315
|
+
recorder.until((state) => state.thread.endsWith('user[read]:"Second." | assistant:"Second answer."')),
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
Effect.provide(testLayer([gateCall, { parts: [text('First answer.')] }, { parts: [text('Second answer.')] }])),
|
|
319
|
+
TestHelpers.provideTestContext,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
it.live(
|
|
324
|
+
'consecutive prompts each run a full indicator cycle',
|
|
325
|
+
Effect.fn(
|
|
326
|
+
function* ({ expect }) {
|
|
327
|
+
const { chatModel, recorder } = yield* setup();
|
|
328
|
+
chatModel.send({ message: 'One?' });
|
|
329
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
330
|
+
const firstCycle = recorder.events.length;
|
|
331
|
+
|
|
332
|
+
chatModel.send({ message: 'Two?' });
|
|
333
|
+
yield* awaitPromise(() =>
|
|
334
|
+
recorder.until((state) => state.active === 'false' && state.thread.endsWith('assistant:"Two."')),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(recorder.values('active')).toEqual(['false', 'true', 'false', 'true', 'false']);
|
|
338
|
+
const second = recorder.events.slice(firstCycle).filter((event) => event.signal === 'activity');
|
|
339
|
+
expect(second[0]?.value).toBe('starting');
|
|
340
|
+
expect(second.at(-1)?.value).toBe('-');
|
|
341
|
+
expect(recorder.violations).toEqual([]);
|
|
342
|
+
},
|
|
343
|
+
Effect.provide(
|
|
344
|
+
testLayer([
|
|
345
|
+
{ delay: '20 millis', parts: [text('One.')] },
|
|
346
|
+
{ delay: '20 millis', parts: [text('Two.')] },
|
|
347
|
+
]),
|
|
348
|
+
),
|
|
349
|
+
TestHelpers.provideTestContext,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
it.live(
|
|
354
|
+
'a remounted chat adopts the turn in flight and shows it as working',
|
|
355
|
+
Effect.fn(
|
|
356
|
+
function* ({ expect }) {
|
|
357
|
+
const { chat, feed, chatModel, recorder } = yield* setup({ skills: [GateSkill] });
|
|
358
|
+
const { started, release } = yield* makeGate;
|
|
359
|
+
chatModel.send({ message: 'Use the gate.' });
|
|
360
|
+
yield* Deferred.await(started);
|
|
361
|
+
|
|
362
|
+
// A second mount of the same chat: a fresh model that did not issue the turn.
|
|
363
|
+
const runtime = yield* Effect.context<Database.Service>();
|
|
364
|
+
const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
|
|
365
|
+
const registry = yield* AtomRegistry.AtomRegistry;
|
|
366
|
+
const remounted = new ChatModel(session, yield* makeTestRuntime, feed, yield* makeSpaceLayer, {
|
|
367
|
+
chat: Ref.make(chat),
|
|
368
|
+
observableRegistry: registry,
|
|
369
|
+
});
|
|
370
|
+
const agent = yield* AgentService.getSession(chat, { location: 'local' });
|
|
371
|
+
const adopted = new ChatModelRecorder(registry, remounted, { agentRunning: agent.running });
|
|
372
|
+
const dispose = remounted.adopt();
|
|
373
|
+
yield* Effect.addFinalizer(() =>
|
|
374
|
+
Effect.sync(() => {
|
|
375
|
+
dispose();
|
|
376
|
+
adopted.dispose();
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
yield* awaitPromise(() => adopted.until((state) => state.active === 'true'));
|
|
381
|
+
// Attaching is asynchronous, so the remounted model reads idle until it has found the session.
|
|
382
|
+
const adoptedAt = adopted.firstAt('active', 'true') ?? 0;
|
|
383
|
+
yield* Deferred.succeed(release, undefined);
|
|
384
|
+
yield* awaitPromise(() => adopted.untilSettled());
|
|
385
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
386
|
+
|
|
387
|
+
expect(adopted.violations.filter((violation) => violation.at > adoptedAt)).toEqual([]);
|
|
388
|
+
expect(adopted.values('activity')).toContain('starting');
|
|
389
|
+
expect(recorder.violations).toEqual([]);
|
|
390
|
+
},
|
|
391
|
+
Effect.provide(testLayer([gateCall, { parts: [text('Gate released.')] }])),
|
|
392
|
+
TestHelpers.provideTestContext,
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
it.live(
|
|
397
|
+
'the indicator log of a tool turn reads in order',
|
|
398
|
+
Effect.fn(
|
|
399
|
+
function* ({ expect }) {
|
|
400
|
+
const { chatModel, recorder } = yield* setup({ skills: [GateSkill] });
|
|
401
|
+
const { started, release } = yield* makeGate;
|
|
402
|
+
chatModel.send({ message: 'Use the gate.' });
|
|
403
|
+
yield* Deferred.await(started);
|
|
404
|
+
yield* Deferred.succeed(release, undefined);
|
|
405
|
+
yield* awaitPromise(() => recorder.untilSettled());
|
|
406
|
+
|
|
407
|
+
expect(recorder.lines([...INDICATORS, 'agent'])).toEqual([
|
|
408
|
+
'active=false',
|
|
409
|
+
'streaming=false',
|
|
410
|
+
'activity=-',
|
|
411
|
+
'error=-',
|
|
412
|
+
'agent=idle',
|
|
413
|
+
// Set locally the moment the prompt is dispatched, before the process exists to say anything.
|
|
414
|
+
'activity=starting',
|
|
415
|
+
'active=true',
|
|
416
|
+
'agent=running',
|
|
417
|
+
'activity=preparing',
|
|
418
|
+
'activity=loading-history',
|
|
419
|
+
'activity=building-toolkit',
|
|
420
|
+
'activity=encoding-prompt',
|
|
421
|
+
'activity=contacting-provider',
|
|
422
|
+
'activity=generating',
|
|
423
|
+
'streaming=true',
|
|
424
|
+
`activity=calling-tool(${Operation.toolName(Gate)})`,
|
|
425
|
+
// The tool's result goes back to the model as a second request within the same turn.
|
|
426
|
+
'activity=building-toolkit',
|
|
427
|
+
'activity=encoding-prompt',
|
|
428
|
+
'activity=contacting-provider',
|
|
429
|
+
'activity=generating',
|
|
430
|
+
// The agent settles first; the chat clears every indicator together once it has noticed.
|
|
431
|
+
'agent=idle',
|
|
432
|
+
'streaming=false',
|
|
433
|
+
'active=false',
|
|
434
|
+
'activity=-',
|
|
435
|
+
]);
|
|
436
|
+
},
|
|
437
|
+
Effect.provide(testLayer([gateCall, { parts: [text('Gate released.')] }])),
|
|
438
|
+
TestHelpers.provideTestContext,
|
|
439
|
+
),
|
|
440
|
+
);
|
|
441
|
+
});
|