@dxos/agent-runtime 0.0.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/LICENSE +105 -0
- package/dist/lib/neutral/chunk-EZKG2ZGG.mjs +754 -0
- package/dist/lib/neutral/chunk-EZKG2ZGG.mjs.map +7 -0
- package/dist/lib/neutral/index.mjs +9 -0
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/testing/index.mjs +285 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/agent-service/AgentService.d.ts +45 -0
- package/dist/types/src/agent-service/AgentService.d.ts.map +1 -0
- package/dist/types/src/agent-service/AgentService.test.d.ts +2 -0
- package/dist/types/src/agent-service/AgentService.test.d.ts.map +1 -0
- package/dist/types/src/agent-service/agent-process.d.ts +457 -0
- package/dist/types/src/agent-service/agent-process.d.ts.map +1 -0
- package/dist/types/src/agent-service/agent-process.test.d.ts +2 -0
- package/dist/types/src/agent-service/agent-process.test.d.ts.map +1 -0
- package/dist/types/src/agent-service/delegation-strategy.d.ts +47 -0
- package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -0
- package/dist/types/src/agent-service/index.d.ts +4 -0
- package/dist/types/src/agent-service/index.d.ts.map +1 -0
- package/dist/types/src/assistant-session-tests/format.test.d.ts +2 -0
- package/dist/types/src/assistant-session-tests/format.test.d.ts.map +1 -0
- package/dist/types/src/assistant-session-tests/request.test.d.ts +2 -0
- package/dist/types/src/assistant-session-tests/request.test.d.ts.map +1 -0
- package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts +2 -0
- package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts.map +1 -0
- package/dist/types/src/assistant-session-tests/session.test.d.ts +2 -0
- package/dist/types/src/assistant-session-tests/session.test.d.ts.map +1 -0
- package/dist/types/src/assistant-session-tests/xml-response.test.d.ts +2 -0
- package/dist/types/src/assistant-session-tests/xml-response.test.d.ts.map +1 -0
- package/dist/types/src/functions.test.d.ts +2 -0
- package/dist/types/src/functions.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/testing/ResearchService.d.ts +28 -0
- package/dist/types/src/testing/ResearchService.d.ts.map +1 -0
- package/dist/types/src/testing/assistant-test-layer.d.ts +70 -0
- package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +4 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/trace-pretty-print.d.ts +16 -0
- package/dist/types/src/testing/trace-pretty-print.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +70 -0
- package/src/agent-service/AgentService.conversations.json +1 -0
- package/src/agent-service/AgentService.test.ts +523 -0
- package/src/agent-service/AgentService.ts +239 -0
- package/src/agent-service/README.md +91 -0
- package/src/agent-service/agent-process.test.ts +206 -0
- package/src/agent-service/agent-process.ts +793 -0
- package/src/agent-service/delegation-strategy.ts +63 -0
- package/src/agent-service/index.ts +7 -0
- package/src/assistant-session-tests/format.test.ts +131 -0
- package/src/assistant-session-tests/request.conversations.json +1 -0
- package/src/assistant-session-tests/request.test.ts +190 -0
- package/src/assistant-session-tests/session-ollama.test.ts +80 -0
- package/src/assistant-session-tests/session.test.ts +56 -0
- package/src/assistant-session-tests/xml-response.conversations.json +1 -0
- package/src/assistant-session-tests/xml-response.test.ts +65 -0
- package/src/functions.conversations.json +1 -0
- package/src/functions.test.ts +87 -0
- package/src/index.ts +7 -0
- package/src/testing/ResearchService.ts +144 -0
- package/src/testing/assistant-test-layer.ts +299 -0
- package/src/testing/index.ts +7 -0
- package/src/testing/trace-pretty-print.ts +55 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { describe, it } from '@effect/vitest';
|
|
6
|
+
import * as Clock from 'effect/Clock';
|
|
7
|
+
import * as DateTime from 'effect/DateTime';
|
|
8
|
+
import * as Duration from 'effect/Duration';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Exit from 'effect/Exit';
|
|
11
|
+
import * as Fiber from 'effect/Fiber';
|
|
12
|
+
import * as Option from 'effect/Option';
|
|
13
|
+
import * as Schema from 'effect/Schema';
|
|
14
|
+
import * as Stream from 'effect/Stream';
|
|
15
|
+
import { expect } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { MemoizedAiService } from '@dxos/ai/testing';
|
|
18
|
+
import { PartialBlock, SessionLink } from '@dxos/assistant';
|
|
19
|
+
import { Operation, OperationHandlerSet, Process, ServiceResolver, Skill, Trace } from '@dxos/compute';
|
|
20
|
+
import { ProcessManager } from '@dxos/compute-runtime';
|
|
21
|
+
import { getSession, hydrate } from '@dxos/compute/AgentService';
|
|
22
|
+
import { Annotation, Feed, Filter, Obj, Ref } from '@dxos/echo';
|
|
23
|
+
import { TestHelpers } from '@dxos/effect/testing';
|
|
24
|
+
import { DXN, EntityId } from '@dxos/keys';
|
|
25
|
+
import { Message, Organization } from '@dxos/types';
|
|
26
|
+
|
|
27
|
+
import { AssistantTestLayer } from '../testing';
|
|
28
|
+
import * as ResearchService from '../testing/ResearchService';
|
|
29
|
+
import { AGENT_PROCESS_KEY } from './agent-process';
|
|
30
|
+
import * as AgentService from './AgentService';
|
|
31
|
+
import { type DelegationStrategy } from './delegation-strategy';
|
|
32
|
+
|
|
33
|
+
EntityId.dangerouslyDisableRandomness();
|
|
34
|
+
|
|
35
|
+
const Research = Operation.make({
|
|
36
|
+
meta: {
|
|
37
|
+
key: DXN.make('org.dxos.function.research'),
|
|
38
|
+
name: 'Research',
|
|
39
|
+
description: 'Research an organization',
|
|
40
|
+
},
|
|
41
|
+
input: Schema.Struct({
|
|
42
|
+
website: Schema.String.annotations({ description: 'The website of the organization to research' }),
|
|
43
|
+
}),
|
|
44
|
+
output: Schema.String,
|
|
45
|
+
services: [ResearchService.ResearchService],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Trivial child operation a {@link DelegationStrategy} can spawn as a sub-agent. Returns a derived
|
|
50
|
+
* string synchronously so the delegation lifecycle can be exercised without an extra LLM turn.
|
|
51
|
+
*/
|
|
52
|
+
const DelegatedWork = Operation.make({
|
|
53
|
+
meta: {
|
|
54
|
+
key: DXN.make('org.dxos.function.delegatedWork'),
|
|
55
|
+
name: 'Delegated work',
|
|
56
|
+
description: 'Performs a delegated unit of work',
|
|
57
|
+
},
|
|
58
|
+
input: Schema.String,
|
|
59
|
+
output: Schema.String,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const handlers = OperationHandlerSet.make(
|
|
63
|
+
Research.pipe(
|
|
64
|
+
Operation.withHandler(
|
|
65
|
+
Effect.fnUntraced(function* ({ website }) {
|
|
66
|
+
const research = yield* ResearchService.ResearchService;
|
|
67
|
+
const result = yield* research.research(website);
|
|
68
|
+
return result;
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
DelegatedWork.pipe(
|
|
73
|
+
Operation.withHandler(
|
|
74
|
+
Effect.fnUntraced(function* (input) {
|
|
75
|
+
return `done: ${input}`;
|
|
76
|
+
}),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const ResearchSkill = Skill.make({
|
|
82
|
+
key: 'org.dxos.skill.research',
|
|
83
|
+
name: 'Research',
|
|
84
|
+
tools: Skill.toolDefinitions({ operations: [Research] }),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const assistantTestLayerOptions = {
|
|
88
|
+
types: [Organization.Organization, Feed.Feed, Skill.Skill],
|
|
89
|
+
tracing: 'pretty' as const,
|
|
90
|
+
aiServicePreset: 'edge-remote' as const,
|
|
91
|
+
operationHandlers: [handlers],
|
|
92
|
+
skills: [ResearchSkill],
|
|
93
|
+
extraServices: ResearchService.layer,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const TestLayer = ({ enableToolBackgrounding = false }: { enableToolBackgrounding?: boolean } = {}) =>
|
|
97
|
+
AssistantTestLayer({
|
|
98
|
+
...assistantTestLayerOptions,
|
|
99
|
+
agent: { enableToolBackgrounding },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
//
|
|
103
|
+
// Delegation (supervisor) fixtures.
|
|
104
|
+
//
|
|
105
|
+
|
|
106
|
+
interface DelegationHarness {
|
|
107
|
+
/** Work the stub strategy delegates on the next reconcile (keyed by a stable id). */
|
|
108
|
+
pending: { id: string; input: string }[];
|
|
109
|
+
/** Completions observed via the strategy's `onComplete` callback. */
|
|
110
|
+
completed: { id: string; exit: Exit.Exit<unknown> }[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const delegationHarness: DelegationHarness = { pending: [], completed: [] };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stub {@link DelegationStrategy} driven by {@link delegationHarness}: each reconcile delegates the
|
|
117
|
+
* pending work not already in flight (spawning {@link DelegatedWork} as a linked child) and records
|
|
118
|
+
* completions, so a test can assert the reconcile → spawn → onChildEvent → onComplete loop.
|
|
119
|
+
*/
|
|
120
|
+
const StubDelegationStrategy: DelegationStrategy = {
|
|
121
|
+
reconcile: (_feed, activeIds) =>
|
|
122
|
+
Effect.succeed(
|
|
123
|
+
delegationHarness.pending
|
|
124
|
+
.filter((work) => !activeIds.has(work.id))
|
|
125
|
+
.map((work) => ({
|
|
126
|
+
id: work.id,
|
|
127
|
+
spawn: Effect.gen(function* () {
|
|
128
|
+
const invoker = yield* ProcessManager.ProcessOperationInvoker.Service;
|
|
129
|
+
const fiber = yield* invoker.invokeFiber(DelegatedWork, work.input);
|
|
130
|
+
return fiber.pid;
|
|
131
|
+
}),
|
|
132
|
+
})),
|
|
133
|
+
),
|
|
134
|
+
onComplete: (_feed, id, exit) =>
|
|
135
|
+
Effect.sync(() => {
|
|
136
|
+
delegationHarness.completed.push({ id, exit });
|
|
137
|
+
// Drop the completed work so a later reconcile does not re-delegate it.
|
|
138
|
+
delegationHarness.pending = delegationHarness.pending.filter((work) => work.id !== id);
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const DelegationTestLayer = AssistantTestLayer({
|
|
143
|
+
...assistantTestLayerOptions,
|
|
144
|
+
agent: { delegationStrategy: StubDelegationStrategy },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('Agent Service', () => {
|
|
148
|
+
it.effect(
|
|
149
|
+
'can answer a question',
|
|
150
|
+
Effect.fnUntraced(
|
|
151
|
+
function* (_) {
|
|
152
|
+
const agent = yield* AgentService.createSession();
|
|
153
|
+
yield* agent.submitPrompt('What is the capital of France?');
|
|
154
|
+
yield* agent.waitForCompletion();
|
|
155
|
+
|
|
156
|
+
const messages = yield* Feed.query(agent.feed, Filter.type(Message.Message)).run;
|
|
157
|
+
const text = messages.map(Message.extractText).join('\n');
|
|
158
|
+
expect(text.toLocaleLowerCase()).toContain('paris');
|
|
159
|
+
},
|
|
160
|
+
Effect.provide(TestLayer()),
|
|
161
|
+
TestHelpers.provideTestContext,
|
|
162
|
+
),
|
|
163
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
it.scoped(
|
|
167
|
+
'tool call',
|
|
168
|
+
Effect.fnUntraced(
|
|
169
|
+
function* (_) {
|
|
170
|
+
const agent = yield* AgentService.createSession({
|
|
171
|
+
skills: [ResearchSkill],
|
|
172
|
+
});
|
|
173
|
+
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
174
|
+
|
|
175
|
+
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
176
|
+
yield* researchService.waitForTaskToAppear();
|
|
177
|
+
yield* researchService.completeOneTask();
|
|
178
|
+
yield* agent.waitForCompletion();
|
|
179
|
+
},
|
|
180
|
+
Effect.provide(TestLayer()),
|
|
181
|
+
TestHelpers.provideTestContext,
|
|
182
|
+
),
|
|
183
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
it.scoped(
|
|
187
|
+
'can be stopped while waiting for a tool call',
|
|
188
|
+
Effect.fnUntraced(
|
|
189
|
+
function* (_) {
|
|
190
|
+
let agent = yield* AgentService.createSession({
|
|
191
|
+
skills: [ResearchSkill],
|
|
192
|
+
});
|
|
193
|
+
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
194
|
+
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
195
|
+
yield* researchService.waitForTaskToAppear();
|
|
196
|
+
|
|
197
|
+
yield* agent.terminate();
|
|
198
|
+
expect(researchService.getTasks().map((task) => task.state)).toEqual(['interrupted']);
|
|
199
|
+
},
|
|
200
|
+
Effect.provide(TestLayer()),
|
|
201
|
+
TestHelpers.provideTestContext,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
it.scoped(
|
|
206
|
+
'restart during tool call',
|
|
207
|
+
Effect.fnUntraced(
|
|
208
|
+
function* (_) {
|
|
209
|
+
let agent = yield* AgentService.createSession({
|
|
210
|
+
skills: [ResearchSkill],
|
|
211
|
+
});
|
|
212
|
+
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
213
|
+
|
|
214
|
+
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
215
|
+
yield* researchService.waitForTaskToAppear();
|
|
216
|
+
|
|
217
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
218
|
+
yield* processManager.shutdown();
|
|
219
|
+
yield* processManager.startup();
|
|
220
|
+
yield* hydrate();
|
|
221
|
+
|
|
222
|
+
// Hydrate returns immediately; redelivery re-issues the research tool on a fresh child.
|
|
223
|
+
// Drain all queued tasks (orphaned pre-restart + live child).
|
|
224
|
+
yield* researchService.waitForTaskToAppear();
|
|
225
|
+
yield* researchService.completeAllTasks();
|
|
226
|
+
|
|
227
|
+
agent = yield* getSession(agent.feed);
|
|
228
|
+
yield* agent.waitForCompletion();
|
|
229
|
+
},
|
|
230
|
+
Effect.provide(TestLayer()),
|
|
231
|
+
TestHelpers.provideTestContext,
|
|
232
|
+
),
|
|
233
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
it.scoped(
|
|
237
|
+
'recovers queued tool results after reload',
|
|
238
|
+
Effect.fnUntraced(
|
|
239
|
+
function* (_) {
|
|
240
|
+
let agent = yield* AgentService.createSession({
|
|
241
|
+
skills: [ResearchSkill],
|
|
242
|
+
});
|
|
243
|
+
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
244
|
+
|
|
245
|
+
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
246
|
+
yield* researchService.waitForTaskToAppear();
|
|
247
|
+
yield* researchService.completeOneTask();
|
|
248
|
+
|
|
249
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
250
|
+
yield* processManager.shutdown();
|
|
251
|
+
yield* processManager.startup();
|
|
252
|
+
yield* hydrate();
|
|
253
|
+
|
|
254
|
+
// Redelivery may re-issue tools from the interrupted turn.
|
|
255
|
+
yield* researchService.waitForTaskToAppear();
|
|
256
|
+
yield* researchService.completeAllTasks();
|
|
257
|
+
|
|
258
|
+
agent = yield* getSession(agent.feed);
|
|
259
|
+
yield* agent.waitForCompletion();
|
|
260
|
+
|
|
261
|
+
const messages = yield* Feed.query(agent.feed, Filter.type(Message.Message)).run;
|
|
262
|
+
const text = messages.map(Message.extractText).join('\n');
|
|
263
|
+
expect(text.toLocaleLowerCase()).toContain('cyberdyne');
|
|
264
|
+
},
|
|
265
|
+
Effect.provide(TestLayer()),
|
|
266
|
+
TestHelpers.provideTestContext,
|
|
267
|
+
),
|
|
268
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
it.scoped(
|
|
272
|
+
'rehydrates an idle session and replays conversation history',
|
|
273
|
+
Effect.fnUntraced(
|
|
274
|
+
function* (_) {
|
|
275
|
+
let agent = yield* AgentService.createSession();
|
|
276
|
+
yield* agent.submitPrompt('What is the capital of France? Reply with just the city name.');
|
|
277
|
+
yield* agent.waitForCompletion();
|
|
278
|
+
|
|
279
|
+
// Simulate app teardown + reboot while the session sits idle (nothing in-flight).
|
|
280
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
281
|
+
yield* processManager.shutdown();
|
|
282
|
+
yield* processManager.startup();
|
|
283
|
+
yield* hydrate();
|
|
284
|
+
|
|
285
|
+
// The rehydrated agent is bound to the same feed, so a follow-up that only makes sense
|
|
286
|
+
// with prior context resolves against the pre-restart turn.
|
|
287
|
+
agent = yield* getSession(agent.feed);
|
|
288
|
+
yield* agent.submitPrompt('What country did I just ask you about? Reply with just the country name.');
|
|
289
|
+
yield* agent.waitForCompletion();
|
|
290
|
+
|
|
291
|
+
const messages = yield* Feed.query(agent.feed, Filter.type(Message.Message)).run;
|
|
292
|
+
const text = messages.map(Message.extractText).join('\n');
|
|
293
|
+
expect(text.toLocaleLowerCase()).toContain('paris');
|
|
294
|
+
expect(text.toLocaleLowerCase()).toContain('france');
|
|
295
|
+
},
|
|
296
|
+
Effect.provide(TestLayer()),
|
|
297
|
+
TestHelpers.provideTestContext,
|
|
298
|
+
),
|
|
299
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
it.scoped(
|
|
303
|
+
'hydrate is a no-op when there are no persisted agents',
|
|
304
|
+
Effect.fnUntraced(
|
|
305
|
+
function* (_) {
|
|
306
|
+
// Reboot over an empty store: hydrate must neither throw nor block, and is idempotent.
|
|
307
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
308
|
+
yield* processManager.shutdown();
|
|
309
|
+
yield* processManager.startup();
|
|
310
|
+
yield* hydrate();
|
|
311
|
+
yield* hydrate();
|
|
312
|
+
},
|
|
313
|
+
Effect.provide(TestLayer()),
|
|
314
|
+
TestHelpers.provideTestContext,
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
it.scoped(
|
|
319
|
+
'runs AI agent with background tools via process manager',
|
|
320
|
+
Effect.fnUntraced(
|
|
321
|
+
function* (_) {
|
|
322
|
+
const agent = yield* AgentService.createSession({
|
|
323
|
+
skills: [ResearchSkill],
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
327
|
+
const taskDrainer = yield* Effect.gen(function* () {
|
|
328
|
+
yield* researchService.waitForTaskToAppear();
|
|
329
|
+
yield* researchService.completeOneTask();
|
|
330
|
+
}).pipe(Effect.forever, Effect.fork);
|
|
331
|
+
|
|
332
|
+
let ephemeralEventCount = 0;
|
|
333
|
+
const ephemeralFiber = yield* agent.subscribeEphemeral().pipe(
|
|
334
|
+
Stream.runForEach((msg) =>
|
|
335
|
+
Effect.gen(function* () {
|
|
336
|
+
for (const event of msg.events) {
|
|
337
|
+
if (Trace.isOfType(PartialBlock, event)) {
|
|
338
|
+
ephemeralEventCount++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
Effect.fork,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
for (const org of ResearchService.getTestData().organizations) {
|
|
347
|
+
yield* agent.submitPrompt(JSON.stringify(org));
|
|
348
|
+
}
|
|
349
|
+
yield* agent.submitPrompt('When all research is complete, print 1-sentence summary for each organization.');
|
|
350
|
+
// TODO(dmaretskyi): wait until settles and only now start draining
|
|
351
|
+
yield* agent.waitForCompletion();
|
|
352
|
+
|
|
353
|
+
yield* Fiber.interrupt(taskDrainer);
|
|
354
|
+
yield* Fiber.interrupt(ephemeralFiber);
|
|
355
|
+
|
|
356
|
+
expect(ephemeralEventCount).toBeGreaterThan(0);
|
|
357
|
+
},
|
|
358
|
+
Effect.provide(TestLayer({ enableToolBackgrounding: true })),
|
|
359
|
+
TestHelpers.provideTestContext,
|
|
360
|
+
),
|
|
361
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 120_000 : undefined },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
describe('delegation (stub)', () => {
|
|
365
|
+
it.scoped(
|
|
366
|
+
'delegates work to a sub-agent and folds the result back on completion',
|
|
367
|
+
Effect.fnUntraced(
|
|
368
|
+
function* (_) {
|
|
369
|
+
delegationHarness.pending = [{ id: 'task-1', input: 'forty-two' }];
|
|
370
|
+
delegationHarness.completed = [];
|
|
371
|
+
|
|
372
|
+
const agent = yield* AgentService.createSession();
|
|
373
|
+
yield* agent.submitPrompt('What is the capital of France?');
|
|
374
|
+
// Settles on the turn's reply; the delegated child runs in the background (not awaited here).
|
|
375
|
+
yield* agent.waitForCompletion();
|
|
376
|
+
|
|
377
|
+
// The post-turn reconcile spawned a linked child; its exit drives onChildEvent → onComplete.
|
|
378
|
+
yield* Effect.promise(async () => {
|
|
379
|
+
await expect.poll(() => delegationHarness.completed.length, { timeout: 5_000 }).toBe(1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const [completion] = delegationHarness.completed;
|
|
383
|
+
expect(completion.id).toBe('task-1');
|
|
384
|
+
expect(Exit.isSuccess(completion.exit)).toBe(true);
|
|
385
|
+
if (Exit.isSuccess(completion.exit)) {
|
|
386
|
+
expect(completion.exit.value).toBe('done: forty-two');
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
Effect.provide(DelegationTestLayer),
|
|
390
|
+
TestHelpers.provideTestContext,
|
|
391
|
+
),
|
|
392
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// The agent's self-wake (set-alarm/get-current-date) flow lives with the alarm blueprint that now
|
|
397
|
+
// provides those tools: `assistant-toolkit/src/skills/alarm/blueprint.test.ts`.
|
|
398
|
+
|
|
399
|
+
// Placed last so it does not perturb the shared deterministic ID stream of the tests above
|
|
400
|
+
// (memoized conversations are keyed per file and depend on prior execution order).
|
|
401
|
+
it.scoped(
|
|
402
|
+
'forks a conversation into a new feed and replays source history via a SessionLink',
|
|
403
|
+
Effect.fnUntraced(
|
|
404
|
+
function* (_) {
|
|
405
|
+
// Original conversation.
|
|
406
|
+
const source = yield* AgentService.createSession();
|
|
407
|
+
yield* source.submitPrompt('What is the capital of France? Reply with just the city name.');
|
|
408
|
+
yield* source.waitForCompletion();
|
|
409
|
+
|
|
410
|
+
// Branch point: the last message of the source conversation.
|
|
411
|
+
const sourceMessages = (yield* Feed.query(source.feed, Filter.type(Message.Message)).run).filter(
|
|
412
|
+
Obj.instanceOf(Message.Message),
|
|
413
|
+
);
|
|
414
|
+
const lastMessage = sourceMessages.sort((a, b) => a.created.localeCompare(b.created)).at(-1);
|
|
415
|
+
if (!lastMessage) {
|
|
416
|
+
return yield* Effect.dieMessage('source conversation produced no messages');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Fork: a fresh session whose feed links back to the source via a SessionLink (mirrors the
|
|
420
|
+
// ForkChat operation). The forked agent has no messages of its own, so a context-dependent
|
|
421
|
+
// follow-up only resolves if the source history is replayed through the link.
|
|
422
|
+
const fork = yield* AgentService.createSession();
|
|
423
|
+
yield* Feed.append(fork.feed, [
|
|
424
|
+
Obj.make(SessionLink.SessionLink, {
|
|
425
|
+
feedRef: Ref.make(source.feed),
|
|
426
|
+
messageId: lastMessage.id,
|
|
427
|
+
}),
|
|
428
|
+
]);
|
|
429
|
+
|
|
430
|
+
yield* fork.submitPrompt('What country did I just ask you about? Reply with just the country name.');
|
|
431
|
+
yield* fork.waitForCompletion();
|
|
432
|
+
|
|
433
|
+
const forkText = (yield* Feed.query(fork.feed, Filter.type(Message.Message)).run)
|
|
434
|
+
.filter(Obj.instanceOf(Message.Message))
|
|
435
|
+
.map(Message.extractText)
|
|
436
|
+
.join('\n');
|
|
437
|
+
expect(forkText.toLocaleLowerCase()).toContain('france');
|
|
438
|
+
},
|
|
439
|
+
Effect.provide(TestLayer()),
|
|
440
|
+
TestHelpers.provideTestContext,
|
|
441
|
+
),
|
|
442
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
// Placed last (like the fork test) so it does not perturb the shared deterministic ID stream of
|
|
446
|
+
// the memoized tests above.
|
|
447
|
+
it.scoped(
|
|
448
|
+
'agent process succeeds when idle and respawns for a follow-up turn',
|
|
449
|
+
Effect.fnUntraced(
|
|
450
|
+
function* (_) {
|
|
451
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
452
|
+
|
|
453
|
+
const agent = yield* AgentService.createSession();
|
|
454
|
+
const target = Obj.getURI(agent.feed);
|
|
455
|
+
|
|
456
|
+
yield* agent.submitPrompt('What is the capital of France? Reply with just the city name.');
|
|
457
|
+
yield* agent.waitForCompletion();
|
|
458
|
+
|
|
459
|
+
// With no queued work, alarms, delegations, or undelivered tool results, the process calls
|
|
460
|
+
// `ctx.succeed()` (see `maybeComplete` / `isAgentWorkPending`) and reaches a terminal state
|
|
461
|
+
// instead of idling.
|
|
462
|
+
const [firstHandle] = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
|
|
463
|
+
const firstPid = String(firstHandle.pid);
|
|
464
|
+
yield* Effect.promise(async () => {
|
|
465
|
+
await expect.poll(() => firstHandle.status.state, { timeout: 5_000 }).toBe(Process.State.SUCCEEDED);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// A follow-up turn does not reuse the succeeded process: `getSession` skips terminal handles
|
|
469
|
+
// and spawns a fresh one, which replays conversation history from the feed.
|
|
470
|
+
const followUp = yield* getSession(agent.feed);
|
|
471
|
+
yield* followUp.submitPrompt('What country did I just ask you about? Reply with just the country name.');
|
|
472
|
+
yield* followUp.waitForCompletion();
|
|
473
|
+
|
|
474
|
+
const processes = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
|
|
475
|
+
expect(processes.some((process) => String(process.pid) !== firstPid)).toBe(true);
|
|
476
|
+
|
|
477
|
+
const messages = yield* Feed.query(agent.feed, Filter.type(Message.Message)).run;
|
|
478
|
+
const text = messages.map(Message.extractText).join('\n');
|
|
479
|
+
expect(text.toLocaleLowerCase()).toContain('france');
|
|
480
|
+
},
|
|
481
|
+
Effect.provide(TestLayer()),
|
|
482
|
+
TestHelpers.provideTestContext,
|
|
483
|
+
),
|
|
484
|
+
{ timeout: MemoizedAiService.isGenerationEnabled() ? 60_000 : undefined },
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// Drives the process control plane directly (no LLM turn), so it is placed after the memoized
|
|
488
|
+
// tests above to avoid perturbing their shared deterministic ID stream.
|
|
489
|
+
it.scoped(
|
|
490
|
+
'setAlarm over the process control surface reaches the live agent and arms a self-wake',
|
|
491
|
+
Effect.fnUntraced(
|
|
492
|
+
function* (_) {
|
|
493
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
494
|
+
|
|
495
|
+
// createSession spawns the agent process (no LLM turn yet) bound to a stamped host marker.
|
|
496
|
+
const agent = yield* AgentService.createSession();
|
|
497
|
+
const target = Obj.getURI(agent.feed);
|
|
498
|
+
const [handle] = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
|
|
499
|
+
|
|
500
|
+
// The spawn stamped the harness-host annotation so the process is discoverable as the owner.
|
|
501
|
+
expect(
|
|
502
|
+
Option.getOrNull(Annotation.getDictionary(handle.params.annotations, Process.HarnessHostAnnotation)),
|
|
503
|
+
).toBe(true);
|
|
504
|
+
|
|
505
|
+
// A Tier-B caller reaches the live AlarmManager over the process RPC loopback. The handler
|
|
506
|
+
// runs on the host's server fiber against the real closed-over AlarmManager; a successful
|
|
507
|
+
// void result proves the control-plane wiring end-to-end (persistence semantics are covered
|
|
508
|
+
// by the AlarmManager unit tests).
|
|
509
|
+
const now = yield* Clock.currentTimeMillis;
|
|
510
|
+
const at = DateTime.unsafeMake(now + Duration.toMillis(Duration.hours(1)));
|
|
511
|
+
yield* handle.rpc.setAlarm({ at, message: 'finish the report' });
|
|
512
|
+
|
|
513
|
+
// The RPC did not fail the process; it remains live and ready for the conversation to resume.
|
|
514
|
+
expect(handle.status.state).not.toBe(Process.State.FAILED);
|
|
515
|
+
expect(handle.status.state).not.toBe(Process.State.TERMINATED);
|
|
516
|
+
|
|
517
|
+
yield* handle.terminate();
|
|
518
|
+
},
|
|
519
|
+
Effect.provide(TestLayer()),
|
|
520
|
+
TestHelpers.provideTestContext,
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
});
|