@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.
Files changed (66) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs +754 -0
  3. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs.map +7 -0
  4. package/dist/lib/neutral/index.mjs +9 -0
  5. package/dist/lib/neutral/index.mjs.map +7 -0
  6. package/dist/lib/neutral/meta.json +1 -0
  7. package/dist/lib/neutral/testing/index.mjs +285 -0
  8. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  9. package/dist/types/src/agent-service/AgentService.d.ts +45 -0
  10. package/dist/types/src/agent-service/AgentService.d.ts.map +1 -0
  11. package/dist/types/src/agent-service/AgentService.test.d.ts +2 -0
  12. package/dist/types/src/agent-service/AgentService.test.d.ts.map +1 -0
  13. package/dist/types/src/agent-service/agent-process.d.ts +457 -0
  14. package/dist/types/src/agent-service/agent-process.d.ts.map +1 -0
  15. package/dist/types/src/agent-service/agent-process.test.d.ts +2 -0
  16. package/dist/types/src/agent-service/agent-process.test.d.ts.map +1 -0
  17. package/dist/types/src/agent-service/delegation-strategy.d.ts +47 -0
  18. package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -0
  19. package/dist/types/src/agent-service/index.d.ts +4 -0
  20. package/dist/types/src/agent-service/index.d.ts.map +1 -0
  21. package/dist/types/src/assistant-session-tests/format.test.d.ts +2 -0
  22. package/dist/types/src/assistant-session-tests/format.test.d.ts.map +1 -0
  23. package/dist/types/src/assistant-session-tests/request.test.d.ts +2 -0
  24. package/dist/types/src/assistant-session-tests/request.test.d.ts.map +1 -0
  25. package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts +2 -0
  26. package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts.map +1 -0
  27. package/dist/types/src/assistant-session-tests/session.test.d.ts +2 -0
  28. package/dist/types/src/assistant-session-tests/session.test.d.ts.map +1 -0
  29. package/dist/types/src/assistant-session-tests/xml-response.test.d.ts +2 -0
  30. package/dist/types/src/assistant-session-tests/xml-response.test.d.ts.map +1 -0
  31. package/dist/types/src/functions.test.d.ts +2 -0
  32. package/dist/types/src/functions.test.d.ts.map +1 -0
  33. package/dist/types/src/index.d.ts +2 -0
  34. package/dist/types/src/index.d.ts.map +1 -0
  35. package/dist/types/src/testing/ResearchService.d.ts +28 -0
  36. package/dist/types/src/testing/ResearchService.d.ts.map +1 -0
  37. package/dist/types/src/testing/assistant-test-layer.d.ts +70 -0
  38. package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -0
  39. package/dist/types/src/testing/index.d.ts +4 -0
  40. package/dist/types/src/testing/index.d.ts.map +1 -0
  41. package/dist/types/src/testing/trace-pretty-print.d.ts +16 -0
  42. package/dist/types/src/testing/trace-pretty-print.d.ts.map +1 -0
  43. package/dist/types/tsconfig.tsbuildinfo +1 -0
  44. package/package.json +70 -0
  45. package/src/agent-service/AgentService.conversations.json +1 -0
  46. package/src/agent-service/AgentService.test.ts +523 -0
  47. package/src/agent-service/AgentService.ts +239 -0
  48. package/src/agent-service/README.md +91 -0
  49. package/src/agent-service/agent-process.test.ts +206 -0
  50. package/src/agent-service/agent-process.ts +793 -0
  51. package/src/agent-service/delegation-strategy.ts +63 -0
  52. package/src/agent-service/index.ts +7 -0
  53. package/src/assistant-session-tests/format.test.ts +131 -0
  54. package/src/assistant-session-tests/request.conversations.json +1 -0
  55. package/src/assistant-session-tests/request.test.ts +190 -0
  56. package/src/assistant-session-tests/session-ollama.test.ts +80 -0
  57. package/src/assistant-session-tests/session.test.ts +56 -0
  58. package/src/assistant-session-tests/xml-response.conversations.json +1 -0
  59. package/src/assistant-session-tests/xml-response.test.ts +65 -0
  60. package/src/functions.conversations.json +1 -0
  61. package/src/functions.test.ts +87 -0
  62. package/src/index.ts +7 -0
  63. package/src/testing/ResearchService.ts +144 -0
  64. package/src/testing/assistant-test-layer.ts +299 -0
  65. package/src/testing/index.ts +7 -0
  66. package/src/testing/trace-pretty-print.ts +55 -0
@@ -0,0 +1,239 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Cause from 'effect/Cause';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+
11
+ import { AiContext } from '@dxos/assistant';
12
+ import { McpServer, Process, Skill } from '@dxos/compute';
13
+ import { ProcessManager } from '@dxos/compute-runtime';
14
+ import {
15
+ AgentService,
16
+ type GetSessionOptions,
17
+ type Service,
18
+ type Session,
19
+ getSession,
20
+ } from '@dxos/compute/AgentService';
21
+ import { Annotation, Database, Feed, Obj, Ref, Registry } from '@dxos/echo';
22
+ import { EffectEx } from '@dxos/effect';
23
+ import { DXN, EID } from '@dxos/keys';
24
+ import { log } from '@dxos/log';
25
+
26
+ import { AGENT_PROCESS_KEY, AgentProcess } from './agent-process';
27
+ import { type DelegationStrategy } from './delegation-strategy';
28
+
29
+ /** The RPC control surface declared by {@link AgentProcess}, recovered from the executable type. */
30
+ type AgentRpcs = ReturnType<typeof AgentProcess> extends Process.Process<any, any, any, infer Rpcs> ? Rpcs : never;
31
+
32
+ /** Live handle to a spawned {@link AgentProcess}, carrying its `HarnessControl` RPC surface. */
33
+ type AgentHandle = ProcessManager.Handle<string, void, AgentRpcs>;
34
+
35
+ const isTerminalProcess = (state: Process.State): boolean =>
36
+ state === Process.State.SUCCEEDED || state === Process.State.FAILED || state === Process.State.TERMINATED;
37
+
38
+ export interface CreateSessionOptions {
39
+ readonly skills?: Skill.Skill[];
40
+ readonly context?: Ref.Ref<Obj.Unknown>[];
41
+ readonly model?: DXN.DXN;
42
+ readonly provider?: DXN.DXN;
43
+ readonly systemPrompt?: string;
44
+ }
45
+
46
+ export const createSession: (
47
+ opts?: CreateSessionOptions,
48
+ ) => Effect.Effect<Session, Skill.NotFoundError, Database.Service | Registry.Service | AgentService> = Effect.fn(
49
+ 'createSession',
50
+ )(function* (opts) {
51
+ const skills = yield* Effect.forEach(opts?.skills ?? [], (skill) =>
52
+ Skill.upsert(Skill.getKey(skill)).pipe(Effect.map(Ref.make)),
53
+ );
54
+
55
+ const feed = yield* Database.add(Feed.make());
56
+ const runtime = yield* Effect.runtime<Database.Service>();
57
+ const binder = yield* EffectEx.acquireReleaseResource(() => new AiContext.Binder({ feed, runtime }));
58
+
59
+ yield* Effect.promise(() =>
60
+ binder.bind({
61
+ skills,
62
+ objects: opts?.context ?? [],
63
+ }),
64
+ );
65
+
66
+ return yield* getSession(feed, { model: opts?.model, provider: opts?.provider });
67
+ }, Effect.scoped);
68
+
69
+ export interface AgentServiceOptions {
70
+ systemPrompt?: string;
71
+ /**
72
+ * Default model used by sessions that don't specify one explicitly.
73
+ */
74
+ model?: DXN.DXN;
75
+
76
+ /**
77
+ * Default provider used to resolve the model for sessions that don't specify one explicitly.
78
+ */
79
+ provider?: DXN.DXN;
80
+
81
+ /**
82
+ * Provider for space-level MCP server configs.
83
+ */
84
+ getMcpServers?: () => McpServer.McpServer[];
85
+
86
+ /**
87
+ * If true, long-running tool calls are moved to the background and the agent is notified
88
+ * asynchronously when they complete. Currently unstable — disabled by default.
89
+ *
90
+ * @default false
91
+ */
92
+ enableToolBackgrounding?: boolean;
93
+
94
+ /**
95
+ * When provided, sessions act as supervisors: the agent delegates outstanding work to sub-agent
96
+ * child processes and folds their results back into the conversation. Absent — a plain agent.
97
+ */
98
+ delegationStrategy?: DelegationStrategy;
99
+ }
100
+
101
+ export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, never, ProcessManager.Service> =>
102
+ Layer.effect(
103
+ AgentService,
104
+ Effect.gen(function* () {
105
+ const processManager = yield* ProcessManager.Service;
106
+ // The agent's model is bound to its process at spawn time, so the cache tracks the model
107
+ // each session was created with. Requesting a different model for the same feed tears down
108
+ // the old process and spawns a fresh one (see below).
109
+ const sessionCache = new Map<
110
+ string,
111
+ { model: DXN.DXN | undefined; provider: DXN.DXN | undefined; handle: AgentHandle; session: Session }
112
+ >();
113
+
114
+ const makeExecutable = (model?: DXN.DXN, provider?: DXN.DXN) =>
115
+ AgentProcess({
116
+ systemPrompt: opts?.systemPrompt,
117
+ model: model ?? opts?.model,
118
+ provider: provider ?? opts?.provider,
119
+ getMcpServers: opts?.getMcpServers,
120
+ enableToolBackgrounding: opts?.enableToolBackgrounding,
121
+ delegationStrategy: opts?.delegationStrategy,
122
+ });
123
+
124
+ const hydrateAgents = Effect.fnUntraced(function* () {
125
+ // Handles cached before shutdown are suspended and no longer registered with the manager.
126
+ sessionCache.clear();
127
+
128
+ const executable = makeExecutable();
129
+ const agents = yield* processManager.list({ key: AGENT_PROCESS_KEY });
130
+ log('agent hydrate', { count: agents.length });
131
+ for (const agent of agents) {
132
+ yield* agent
133
+ .hydrate(executable)
134
+ .pipe(
135
+ Effect.catchAllCause((cause) =>
136
+ Effect.sync(() => log.warn('agent hydrate skipped', { pid: agent.pid, cause: Cause.pretty(cause) })),
137
+ ),
138
+ );
139
+ }
140
+ });
141
+
142
+ const service: Service = {
143
+ getSession: (feed: Feed.Feed, options?: GetSessionOptions) =>
144
+ Effect.gen(function* () {
145
+ const model = options?.model ?? opts?.model;
146
+ const provider = options?.provider ?? opts?.provider;
147
+ const cached = sessionCache.get(feed.id);
148
+ if (cached) {
149
+ if (
150
+ cached.model === model &&
151
+ cached.provider === provider &&
152
+ !isTerminalProcess(cached.handle.status.state)
153
+ ) {
154
+ return cached.session;
155
+ }
156
+
157
+ if (!isTerminalProcess(cached.handle.status.state)) {
158
+ // Model or provider changed (e.g. the user toggled online/offline): terminate the
159
+ // existing process so the conversation continues on a fresh process bound to the new
160
+ // model. Conversation history is preserved via the feed, which the new process replays.
161
+ yield* cached.handle.terminate();
162
+ }
163
+ sessionCache.delete(feed.id);
164
+ }
165
+
166
+ const target = Obj.getURI(feed);
167
+ const parsedEchoUri = EID.tryParse(target);
168
+ const spaceId = parsedEchoUri ? EID.getSpaceId(parsedEchoUri) : undefined;
169
+ const executable = makeExecutable(model, provider);
170
+
171
+ // Reuse a still-running process for this feed only when there was no cached session
172
+ // (e.g. after the UI remounted). After a model change we always spawn a fresh process,
173
+ // since the process key does not encode the model.
174
+ const processes = yield* processManager.list({ target, key: executable.key });
175
+ const activeProcess = processes.find((process) => !isTerminalProcess(process.status.state));
176
+
177
+ let handle: AgentHandle;
178
+ if (activeProcess) {
179
+ yield* activeProcess.hydrate(executable);
180
+ handle = activeProcess;
181
+ } else {
182
+ handle = yield* processManager.spawn(executable, {
183
+ name: 'Agent',
184
+ target,
185
+ // Stamp the host marker so the harness control surface is discoverable by annotation
186
+ // lookup (set once at spawn, immutable — the identity plane).
187
+ annotations: Annotation.buildDictionary((dictionary) => {
188
+ Annotation.setDictionary(dictionary, Process.HarnessHostAnnotation, true);
189
+ }),
190
+ environment: {
191
+ ...(spaceId !== undefined ? { space: spaceId } : {}),
192
+ conversation: target,
193
+ },
194
+ traceMeta: {
195
+ conversation: Ref.make(feed),
196
+ },
197
+ });
198
+ }
199
+
200
+ const releaseSession = () => {
201
+ sessionCache.delete(feed.id);
202
+ };
203
+ const session = makeSession(handle, feed, releaseSession);
204
+ sessionCache.set(feed.id, { model, provider, handle, session });
205
+ return session;
206
+ }),
207
+ hydrate: hydrateAgents,
208
+ };
209
+
210
+ return service;
211
+ }),
212
+ );
213
+
214
+ const makeSession = (process: AgentHandle, feed: Feed.Feed, releaseSession: () => void): Session => ({
215
+ feed,
216
+ getContext: () =>
217
+ Effect.gen(function* () {
218
+ const runtime = yield* Effect.runtime<Database.Service>();
219
+ const binder = yield* EffectEx.acquireReleaseResource(() => new AiContext.Binder({ feed, runtime }));
220
+ return binder.getObjects().map((object) => Ref.make(object));
221
+ }).pipe(Effect.scoped),
222
+ addContext: (context: Ref.Ref<Obj.Unknown>[]) =>
223
+ Effect.gen(function* () {
224
+ const runtime = yield* Effect.runtime<Database.Service>();
225
+ const binder = yield* EffectEx.acquireReleaseResource(() => new AiContext.Binder({ feed, runtime }));
226
+ yield* Effect.promise(() =>
227
+ binder.bind({
228
+ skills: [],
229
+ objects: context,
230
+ }),
231
+ );
232
+ }).pipe(Effect.scoped),
233
+ submitPrompt: (prompt: string) => process.submitInput(prompt),
234
+ // Settle when the turn's reply is complete; do NOT block on background sub-agents
235
+ // (a supervisor delegates work that runs after the turn and reports back out of band).
236
+ waitForCompletion: () => process.runUntilSettled(),
237
+ terminate: () => process.terminate().pipe(Effect.tap(() => Effect.sync(releaseSession))),
238
+ subscribeEphemeral: () => process.subscribeEphemeral(),
239
+ });
@@ -0,0 +1,91 @@
1
+ # Agent Service
2
+
3
+ `AgentService` spawns and caches a process-backed **agent** per conversation feed. The agent is a
4
+ long-lived `AgentProcess` that handles user turns; when a `DelegationStrategy` is injected it also
5
+ acts as a **supervisor** that delegates work to linked child processes and folds their results back
6
+ into the conversation.
7
+
8
+ ## Layering
9
+
10
+ The supervisor behaviour is split across three layers so `@dxos/functions-runtime` stays agnostic of
11
+ the agent/plan ECHO types (it cannot depend on `@dxos/assistant-toolkit`).
12
+
13
+ ```mermaid
14
+ flowchart TB
15
+ subgraph rt["@dxos/compute-runtime"]
16
+ POI["ProcessOperationInvoker<br/>invokeFiber() / attachFiber()<br/><i>(linked child)</i>"]
17
+ end
18
+
19
+ subgraph fr["@dxos/functions-runtime · agent-service"]
20
+ AS["AgentService.layer({ delegationStrategy? })"]
21
+ AP["AgentProcess<br/>onAlarm (turn) · onChildEvent (wake)<br/>DelegationsKey: pid → id"]
22
+ SS["DelegationStrategy <i>(type-only seam)</i><br/>reconcile() / onComplete()"]
23
+ AS --> AP
24
+ AP -. "calls" .-> SS
25
+ AP --> POI
26
+ end
27
+
28
+ subgraph tk["@dxos/assistant-toolkit"]
29
+ IMPL["makeDelegationStrategy()<br/>reconcile: delegated plan tasks → Routine + AgentPrompt<br/>onComplete: update Task · addArtifact · post message"]
30
+ end
31
+
32
+ subgraph pa["plugin-automation"]
33
+ CAP["AgentDelegationStrategy capability"]
34
+ end
35
+
36
+ IMPL -. "implements" .-> SS
37
+ CAP -. "provides impl to" .-> AS
38
+ IMPL --> CAP
39
+ ```
40
+
41
+ - **`ProcessOperationInvoker`** (runtime primitive, no AI): `invokeFiber` spawns a linked,
42
+ non-blocking child and returns a fiber with `pid`; `attachFiber` + `fiber.await` reads the
43
+ finished child's `Exit`.
44
+ - **`DelegationStrategy`** (this dir, type-only): the pluggable policy `AgentProcess` calls —
45
+ `reconcile` (what to delegate) and `onComplete` (how to fold a result back). Absent → plain chat.
46
+ - **`makeDelegationStrategy()`** (assistant-toolkit): the concrete, agent/plan-aware implementation,
47
+ contributed by `plugin-automation` and injected through `AgentService.layer`.
48
+
49
+ ## Delegation lifecycle
50
+
51
+ The child's exit is only a **wake signal** — its output value is read separately via
52
+ `attachFiber` + `fiber.await` (the `ChildEvent` does not carry the payload). `DelegationsKey` persists the
53
+ `pid → id` map so a child that exits after the supervisor hibernates can still be correlated.
54
+
55
+ ```mermaid
56
+ sequenceDiagram
57
+ participant U as User
58
+ participant AP as AgentProcess (supervisor)
59
+ participant ST as DelegationStrategy
60
+ participant POI as ProcessOperationInvoker
61
+ participant CH as Sub-agent (child process)
62
+
63
+ U->>AP: submitInput(prompt)
64
+ AP->>AP: onAlarm → run AiSession turn (tools may record delegated plan tasks)
65
+ AP->>ST: reconcile(feed, activeIds)
66
+ ST-->>AP: Delegation[] { id, spawn }
67
+ loop per delegation
68
+ AP->>POI: invokeFiber(op, input)
69
+ POI->>CH: linked child (non-blocking)
70
+ POI-->>AP: pid
71
+ AP->>AP: DelegationsKey.set(pid → id)
72
+ end
73
+ Note over AP: turn settles via runUntilSettled — supervisor stays IDLE/HYBERNATING and accepts more input
74
+
75
+ CH-->>AP: exit (wake only — no payload)
76
+ AP->>AP: onChildEvent: match pid → id, drop from DelegationsKey
77
+ AP->>POI: attachFiber(pid) + await
78
+ POI-->>AP: Exit of output value
79
+ AP->>ST: onComplete(feed, id, exit)
80
+ ST->>U: update Task status + post message (reference blocks → dx-anchor)
81
+ ```
82
+
83
+ ## Key types
84
+
85
+ | Symbol | Where | Role |
86
+ | --- | --- | --- |
87
+ | `AgentService` / `layer` | `AgentService.ts` | Per-feed session cache (model-aware); wires `delegationStrategy` into `AgentProcess`. |
88
+ | `AgentProcess` | `agent-process.ts` | Turn loop (`onAlarm`) + child-exit wake (`onChildEvent`); owns `DelegationsKey`. |
89
+ | `DelegationStrategy`, `Delegation` | `delegation-strategy.ts` | Type-only seam: `reconcile` / `onComplete`; `Delegation = { id, spawn }`. |
90
+ | `ProcessOperationInvoker.invokeFiber` / `attachFiber` | `@dxos/compute-runtime` | Linked-child spawn + result read. |
91
+ | `makeDelegationStrategy()` | `@dxos/assistant-toolkit` | Concrete agent/plan-aware strategy. |
@@ -0,0 +1,206 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as KeyValueStore from '@effect/platform/KeyValueStore';
6
+ import { describe, it } from '@effect/vitest';
7
+ import * as Effect from 'effect/Effect';
8
+
9
+ import { Process } from '@dxos/compute';
10
+ import { storageServiceLayer } from '@dxos/compute-runtime';
11
+ import { ContentBlock } from '@dxos/types';
12
+
13
+ import { AlarmManager, computeAlarmDelay, isAgentWorkPending } from './agent-process';
14
+
15
+ const NOW = new Date('2026-06-04T12:00:00.000Z').getTime();
16
+
17
+ /** Builds a fresh in-memory storage service for a single test. */
18
+ const makeStorage = Effect.gen(function* () {
19
+ const kvStore = yield* KeyValueStore.KeyValueStore;
20
+ return storageServiceLayer(kvStore, 'agent/');
21
+ }).pipe(Effect.provide(KeyValueStore.layerMemory));
22
+
23
+ /** AlarmManager with a captured `setAlarm` and a fixed clock for deterministic assertions. */
24
+ const makeManager = Effect.fnUntraced(function* (now: number = NOW) {
25
+ const storageService = yield* makeStorage;
26
+ const alarmCalls: Array<number | undefined> = [];
27
+ const alarmManager = new AlarmManager({
28
+ storageService,
29
+ setAlarm: (timeout) => alarmCalls.push(timeout),
30
+ now: () => now,
31
+ });
32
+ return { alarmManager, alarmCalls, storageService };
33
+ });
34
+
35
+ describe('computeAlarmDelay', () => {
36
+ it('wakes immediately when there is pending work', ({ expect }) => {
37
+ expect(computeAlarmDelay({ hasPendingWork: true, wakeAt: NOW + 10_000, now: NOW })).toBe(0);
38
+ expect(computeAlarmDelay({ hasPendingWork: true, wakeAt: null, now: NOW })).toBe(0);
39
+ });
40
+
41
+ it('schedules a future self-wake when idle', ({ expect }) => {
42
+ expect(computeAlarmDelay({ hasPendingWork: false, wakeAt: NOW + 10_000, now: NOW })).toBe(10_000);
43
+ });
44
+
45
+ it('wakes immediately when a self-wake is already due', ({ expect }) => {
46
+ expect(computeAlarmDelay({ hasPendingWork: false, wakeAt: NOW - 10_000, now: NOW })).toBe(0);
47
+ });
48
+
49
+ it('schedules nothing when idle and no self-wake is set', ({ expect }) => {
50
+ expect(computeAlarmDelay({ hasPendingWork: false, wakeAt: null, now: NOW })).toBe(null);
51
+ });
52
+ });
53
+
54
+ describe('AlarmManager', () => {
55
+ it.effect(
56
+ 'tracks and persists the next self-wake, syncing with setAlarm',
57
+ Effect.fnUntraced(function* ({ expect }) {
58
+ const { alarmManager, alarmCalls, storageService } = yield* makeManager();
59
+
60
+ yield* alarmManager.load();
61
+ expect(alarmManager.wakeAt).toBe(null);
62
+
63
+ yield* alarmManager.setWakeAt(NOW + 60_000);
64
+ expect(alarmManager.wakeAt).toBe(NOW + 60_000);
65
+
66
+ // Reconcile schedules the underlying process alarm for the self-wake delay.
67
+ alarmManager.reconcile(false);
68
+ expect(alarmCalls).toEqual([60_000]);
69
+
70
+ // A new manager backed by the same storage recovers the persisted alarm.
71
+ const recovered = new AlarmManager({ storageService, setAlarm: () => {}, now: () => NOW });
72
+ yield* recovered.load();
73
+ expect(recovered.wakeAt).toBe(NOW + 60_000);
74
+ }),
75
+ );
76
+
77
+ it.effect(
78
+ 'reconcile prefers pending work over a future self-wake',
79
+ Effect.fnUntraced(function* ({ expect }) {
80
+ const { alarmManager, alarmCalls } = yield* makeManager();
81
+ yield* alarmManager.setWakeAt(NOW + 60_000);
82
+
83
+ alarmManager.reconcile(true);
84
+ expect(alarmCalls).toEqual([0]);
85
+ }),
86
+ );
87
+
88
+ it.effect(
89
+ 'reconcile schedules nothing when idle without a self-wake',
90
+ Effect.fnUntraced(function* ({ expect }) {
91
+ const { alarmManager, alarmCalls } = yield* makeManager();
92
+ yield* alarmManager.load();
93
+
94
+ alarmManager.reconcile(false);
95
+ expect(alarmCalls).toEqual([]);
96
+ }),
97
+ );
98
+
99
+ it.effect(
100
+ 'takeFiredAlarm clears and returns a due alarm, persisting the cleared state',
101
+ Effect.fnUntraced(function* ({ expect }) {
102
+ const { alarmManager, storageService } = yield* makeManager();
103
+ yield* alarmManager.setWakeAt(NOW - 1_000);
104
+
105
+ const fired = yield* alarmManager.takeFiredAlarm();
106
+ expect(fired).toEqual({ firedAt: NOW - 1_000, message: null });
107
+ expect(alarmManager.wakeAt).toBe(null);
108
+
109
+ const recovered = new AlarmManager({ storageService, setAlarm: () => {}, now: () => NOW });
110
+ yield* recovered.load();
111
+ expect(recovered.wakeAt).toBe(null);
112
+ }),
113
+ );
114
+
115
+ it.effect(
116
+ 'takeFiredAlarm leaves a future alarm untouched',
117
+ Effect.fnUntraced(function* ({ expect }) {
118
+ const { alarmManager } = yield* makeManager();
119
+ yield* alarmManager.setWakeAt(NOW + 60_000);
120
+
121
+ const fired = yield* alarmManager.takeFiredAlarm();
122
+ expect(fired).toBe(null);
123
+ expect(alarmManager.wakeAt).toBe(NOW + 60_000);
124
+ }),
125
+ );
126
+
127
+ it.effect(
128
+ 'persists and restores the reminder message alongside the self-wake',
129
+ Effect.fnUntraced(function* ({ expect }) {
130
+ const { alarmManager, storageService } = yield* makeManager();
131
+ yield* alarmManager.setWakeAt(NOW - 1_000, 'check the build');
132
+ expect(alarmManager.message).toBe('check the build');
133
+
134
+ // A new manager backed by the same storage recovers both the timestamp and the message.
135
+ const recovered = new AlarmManager({ storageService, setAlarm: () => {}, now: () => NOW });
136
+ yield* recovered.load();
137
+ expect(recovered.wakeAt).toBe(NOW - 1_000);
138
+ expect(recovered.message).toBe('check the build');
139
+
140
+ const fired = yield* recovered.takeFiredAlarm();
141
+ expect(fired).toEqual({ firedAt: NOW - 1_000, message: 'check the build' });
142
+ expect(recovered.message).toBe(null);
143
+ }),
144
+ );
145
+ });
146
+
147
+ // The completion decision (`maybeComplete` → `ctx.succeed()`) is exercised at two levels: the
148
+ // `isAgentWorkPending` suite below covers the predicate the process consults (queue / alarm /
149
+ // delegations / pending tool results), and `AgentService.test.ts` covers the process reaching a
150
+ // terminal state and respawning for a follow-up turn end-to-end.
151
+
152
+ describe('isAgentWorkPending', () => {
153
+ const makeSnapshot = (overrides: Partial<Parameters<typeof isAgentWorkPending>[0]> = {}) => {
154
+ const storageService = Effect.runSync(makeStorage);
155
+ const alarmManager = new AlarmManager({ storageService, setAlarm: () => {} });
156
+ return {
157
+ inputQueue: [],
158
+ alarmManager,
159
+ delegations: [],
160
+ toolCallManager: {
161
+ hasPendingToolResults: () => false,
162
+ },
163
+ ...overrides,
164
+ } satisfies Parameters<typeof isAgentWorkPending>[0];
165
+ };
166
+
167
+ it('is idle when nothing is pending', ({ expect }) => {
168
+ expect(isAgentWorkPending(makeSnapshot())).toBe(false);
169
+ });
170
+
171
+ it('is pending when the input queue has work', ({ expect }) => {
172
+ expect(
173
+ isAgentWorkPending(
174
+ makeSnapshot({
175
+ inputQueue: [{ _tag: 'prompt', content: [ContentBlock.Text.make({ text: 'hello' })] }],
176
+ }),
177
+ ),
178
+ ).toBe(true);
179
+ });
180
+
181
+ it('is pending when a self-wake alarm is scheduled', ({ expect }) => {
182
+ const snapshot = makeSnapshot();
183
+ Effect.runSync(snapshot.alarmManager.setWakeAt(NOW + 60_000));
184
+ expect(isAgentWorkPending(snapshot)).toBe(true);
185
+ });
186
+
187
+ it('is pending when subprocess delegations are in flight', ({ expect }) => {
188
+ expect(
189
+ isAgentWorkPending(
190
+ makeSnapshot({
191
+ delegations: [{ pid: Process.ID.make('child-1'), id: 'task-1' }],
192
+ }),
193
+ ),
194
+ ).toBe(true);
195
+ });
196
+
197
+ it('is pending when tool results have not been delivered', ({ expect }) => {
198
+ expect(
199
+ isAgentWorkPending(
200
+ makeSnapshot({
201
+ toolCallManager: { hasPendingToolResults: () => true },
202
+ }),
203
+ ),
204
+ ).toBe(true);
205
+ });
206
+ });