@agentswf/engine 0.0.0-stage → 0.1.0-beta.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 (77) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/jsonl.ts +74 -0
  29. package/src/logs-command.ts +605 -0
  30. package/src/machine.ts +27 -0
  31. package/src/message-operation.ts +245 -0
  32. package/src/messaging/limits.ts +35 -0
  33. package/src/messaging/messaging.ts +1303 -0
  34. package/src/messaging/routes.ts +79 -0
  35. package/src/messaging/text.ts +251 -0
  36. package/src/operation-events.ts +182 -0
  37. package/src/operation-liveness.ts +674 -0
  38. package/src/operator-aliases.ts +10 -0
  39. package/src/operator-cli.ts +569 -0
  40. package/src/operator-runtime.ts +503 -0
  41. package/src/pane-layout.ts +125 -0
  42. package/src/progress-view.ts +568 -0
  43. package/src/result-slots.ts +325 -0
  44. package/src/result-validation.ts +47 -0
  45. package/src/run-command.ts +241 -0
  46. package/src/run-commands.ts +215 -0
  47. package/src/run-event-log.ts +72 -0
  48. package/src/run-prepare.ts +178 -0
  49. package/src/run-progress.ts +137 -0
  50. package/src/run-read.ts +425 -0
  51. package/src/run-state.ts +120 -0
  52. package/src/run-usage.ts +393 -0
  53. package/src/run-view.ts +225 -0
  54. package/src/runs-command.ts +356 -0
  55. package/src/runs.ts +645 -0
  56. package/src/sandbox-homes.ts +211 -0
  57. package/src/sandboxes.ts +516 -0
  58. package/src/skills/fetch.ts +305 -0
  59. package/src/skills/run-skills.ts +181 -0
  60. package/src/skills/sources.ts +87 -0
  61. package/src/skills/tree.ts +60 -0
  62. package/src/stage-ledger.ts +535 -0
  63. package/src/stage-plan.ts +127 -0
  64. package/src/stage-schema.ts +88 -0
  65. package/src/stopped.ts +36 -0
  66. package/src/test-command.ts +110 -0
  67. package/src/testing/operation-events.ts +89 -0
  68. package/src/testing.ts +164 -0
  69. package/src/workflow-loader.ts +113 -0
  70. package/src/workflow-runner.ts +3665 -0
  71. package/src/workflow-test-preload.ts +13 -0
  72. package/src/workflow-testing/decisions.ts +129 -0
  73. package/src/workflow-testing/host.ts +811 -0
  74. package/src/workflow-testing/index.ts +420 -0
  75. package/src/workflow-testing/script.ts +447 -0
  76. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  77. package/README.md +0 -3
@@ -0,0 +1,811 @@
1
+ import type { SandboxRecord } from "@agentswf/contract/records";
2
+ import {
3
+ type MessageResponse,
4
+ REPLY_CODES,
5
+ type ReplyCode,
6
+ SEND_CODES,
7
+ type SendCode,
8
+ WIRE_VERSION,
9
+ } from "@agentswf/contract/wire";
10
+ import {
11
+ type AgentExecution,
12
+ type AgentPlacement,
13
+ type Effort,
14
+ type JsonObject,
15
+ type KeepPane,
16
+ type PaneLayout,
17
+ placementOf,
18
+ } from "@agentswf/contract/workflow";
19
+ import {
20
+ createPlacementHostFactory,
21
+ createSingleSessionHostFactory,
22
+ findHarness,
23
+ headlessRefusal,
24
+ loginFailure,
25
+ PLACEMENT_HARNESSES,
26
+ settingsRefusal,
27
+ } from "@agentswf/harness";
28
+ import type { AgentRunHostFactory, HarnessActivation } from "@agentswf/harness/adapter";
29
+ import {
30
+ createFakeAdapter,
31
+ createFakeCallingSession,
32
+ type FakeAdapterTurn,
33
+ type FakeAdapterTurnContext,
34
+ type FakeFork,
35
+ type FakeSet,
36
+ } from "@agentswf/harness/testing";
37
+ import { submitControl, submitResult, submitWaiting } from "@agentswf/wf/client";
38
+ import {
39
+ type Message,
40
+ type MessageOutcome,
41
+ type MessageScripts,
42
+ type ReceivedMessage,
43
+ reply,
44
+ type Scripts,
45
+ type SendOptions,
46
+ type Step,
47
+ type Turn,
48
+ type TurnOutcome,
49
+ } from "./script";
50
+
51
+ /**
52
+ * A turn as the workflow wrote it, and how it ended. `messages` are those it was shown, absent when
53
+ * none were.
54
+ */
55
+ export type TurnRecord = Omit<Turn, "signal" | "messages" | "send" | "reply"> & {
56
+ messages?: readonly Message[];
57
+ outcome: TurnOutcome;
58
+ };
59
+
60
+ /** A compaction as the workflow asked for it, and how it ended. */
61
+ export type CompactionRecord = {
62
+ agent: string;
63
+ /** 1-based among the agent's compactions. */
64
+ n: number;
65
+ id: string;
66
+ /** The spec's prompt: what the harness is to keep and drop. */
67
+ focus: string;
68
+ outcome: TurnOutcome;
69
+ };
70
+
71
+ /** A switch of an agent's model or effort, as the host was asked for it. */
72
+ export type SetRecord = {
73
+ agent: string;
74
+ /** 1-based among the agent's switches. */
75
+ n: number;
76
+ /** The settings switched to, whole. */
77
+ model: string;
78
+ effort?: Effort;
79
+ };
80
+
81
+ /** An agent the workflow opened, as it opened it. */
82
+ export type OpenedAgent = {
83
+ key: string;
84
+ execution: AgentExecution;
85
+ instructions?: string;
86
+ labels?: JsonObject;
87
+ /** The skills it was given by name; absent when the workflow named none. */
88
+ skills?: readonly string[];
89
+ /** The sandbox it ran in; absent on the host. */
90
+ sandbox?: AgentSandbox;
91
+ /**
92
+ * The agent it was forked from, or the session the run was started from, and how many of its
93
+ * turns in this run the copy holds.
94
+ */
95
+ forkedFrom?: { key: string; turns: number } | { caller: true; turns: number };
96
+ /**
97
+ * Its pane's layout as the workflow wrote it; this host has no screen, so nothing says where a
98
+ * pane landed or whether a `beside` fell back.
99
+ */
100
+ layout?: PaneLayout;
101
+ /** Why the engine already knew `layout` could not be used: a `beside` an agent with no pane. */
102
+ layoutFallback?: string;
103
+ keepPane?: KeepPane;
104
+ /** Its pane was kept when it was done: `keepPane` asked, and how its last operation ended let it. */
105
+ keptPane?: true;
106
+ };
107
+
108
+ /** The sandbox an agent ran in, as the run's record keeps it. */
109
+ export type AgentSandbox = Pick<SandboxRecord, "key" | "provider" | "spec" | "domains">;
110
+
111
+ export type ScriptedHost = {
112
+ factory: AgentRunHostFactory;
113
+ turns: TurnRecord[];
114
+ compactions: CompactionRecord[];
115
+ sets: SetRecord[];
116
+ agents: OpenedAgent[];
117
+ /** Turns started and not yet ended, for a stalled run's message. */
118
+ inFlight(): TurnRecord[];
119
+ };
120
+
121
+ /**
122
+ * The run host a workflow's test runs on: the production session core behind each placement,
123
+ * over a fake adapter that answers from the scripts. It refuses the harnesses each real host
124
+ * refuses; the fake sandbox provider hosts panes, so no sandbox is refused one.
125
+ */
126
+ export function createScriptedHost(
127
+ scripts: Scripts,
128
+ /** Unscripted, an agent's compactions all answer `""`. */
129
+ compactionScripts: Scripts,
130
+ messageScripts: MessageScripts,
131
+ events: {
132
+ /** A turn started or ended: the run is not stalled. */
133
+ onActivity(): void;
134
+ /** The script could not meet a turn: the test fails. */
135
+ onScriptError(message: string): void;
136
+ },
137
+ /**
138
+ * The session the run is started from, as `awf run --here` finds one, and the model its files
139
+ * show, which a fork of it runs on (ADR 0010).
140
+ */
141
+ caller?: { harness: string; cwd: string; model?: string; here?: boolean },
142
+ ): ScriptedHost {
143
+ const turns: TurnRecord[] = [];
144
+ const compactions: CompactionRecord[] = [];
145
+ const compactionCounts = new Map<string, number>();
146
+ const sets: SetRecord[] = [];
147
+ const agents: OpenedAgent[] = [];
148
+ const open = new Set<TurnRecord>();
149
+ const counts = new Map<string, number>();
150
+ /** Each fork's session, by the agent it copied and that agent's turns so far. */
151
+ const forks = new Map<string, NonNullable<OpenedAgent["forkedFrom"]>>();
152
+ /** The calling session's key, once the workflow has it as an agent. */
153
+ let callerKey: string | undefined;
154
+ // As a real run has it: a session whose files name no model is none to fork.
155
+ const calling =
156
+ caller &&
157
+ (caller.model === undefined
158
+ ? `${caller.harness} session fake-calling has nothing to fork: its files hold no request naming a model, as in its first turn`
159
+ : createFakeCallingSession({
160
+ harness: caller.harness,
161
+ cwd: caller.cwd,
162
+ model: caller.model,
163
+ onFork: (sessionRef) =>
164
+ forks.set(sessionRef, {
165
+ caller: true,
166
+ turns: callerKey === undefined ? 0 : (counts.get(callerKey) ?? 0),
167
+ }),
168
+ }));
169
+ const end = (record: TurnRecord, outcome: TurnOutcome) => {
170
+ record.outcome = outcome;
171
+ open.delete(record);
172
+ events.onActivity();
173
+ };
174
+ const failTest = (message: string): FakeAdapterTurn => {
175
+ events.onScriptError(message);
176
+ return { state: "failed", detail: message };
177
+ };
178
+ /** Each operation's calls in flight, by its call id. */
179
+ const operations = new Map<string, Operation>();
180
+ /** How many workflow turns, and how many message operations, each agent has had. */
181
+ const workflowTurns = new Map<string, number>();
182
+ const messageOperations = new Map<string, number>();
183
+ const next = (counter: Map<string, number>, agent: string) => {
184
+ const n = (counter.get(agent) ?? 0) + 1;
185
+ counter.set(agent, n);
186
+ return n;
187
+ };
188
+
189
+ /** `wf send` or `wf reply` from a running script; with `awaitReply`, resolves with the reply. */
190
+ const message = async (
191
+ invocation: Invocation,
192
+ command: "send" | "reply",
193
+ peer: string,
194
+ text: string,
195
+ options: SendOptions = {},
196
+ ): Promise<MessageOutcome> => {
197
+ const { operation } = invocation;
198
+ if (invocation.state !== "running") {
199
+ const why = `agent "${invocation.turn.agent}" turn ${invocation.turn.n}: its script sent after its turn ended; await only the host's calls while a reply is awaited`;
200
+ failTest(why);
201
+ throw new Error(why);
202
+ }
203
+ const call = submitControl(operation.binding.endpoint, {
204
+ version: WIRE_VERSION,
205
+ command,
206
+ peer,
207
+ text,
208
+ ...(options.awaitReply ? { awaitReply: true as const } : {}),
209
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
210
+ });
211
+ invocation.calls.add(call);
212
+ let response: MessageResponse;
213
+ try {
214
+ response = await call;
215
+ } finally {
216
+ invocation.calls.delete(call);
217
+ }
218
+ if (response.kind === "rejected") {
219
+ if (MESSAGE_CODES.has(response.code)) {
220
+ suspendIfAwaiting(invocation);
221
+ return { kind: "refused", code: response.code as SendCode | ReplyCode };
222
+ }
223
+ throw new Error(`wf ${command} was refused (${response.code}): ${response.error}`);
224
+ }
225
+ if (!options.awaitReply) {
226
+ suspendIfAwaiting(invocation);
227
+ return { kind: "sent" };
228
+ }
229
+ return new Promise((resolve) => {
230
+ operation.awaiting.push({ peer, by: invocation, resolve });
231
+ suspendIfAwaiting(invocation);
232
+ });
233
+ };
234
+
235
+ /**
236
+ * Ends the native turn when its script has gone still awaiting a reply, as an agent ends its turn
237
+ * after `wf send --await-reply`: nothing of the host's in flight, and no step taken since.
238
+ */
239
+ const suspendIfAwaiting = (invocation: Invocation) => {
240
+ setTimeout(() => {
241
+ const { operation } = invocation;
242
+ if (
243
+ invocation.state === "running" &&
244
+ invocation.calls.size === 0 &&
245
+ operation.awaiting.some((each) => each.by === invocation)
246
+ ) {
247
+ invocation.stopped.resolve("suspended");
248
+ }
249
+ }, 0);
250
+ };
251
+
252
+ /** Starts the script for this turn: the agent's turn script, or a message script when one woke it. */
253
+ const invoke = (
254
+ context: FakeAdapterTurnContext,
255
+ operation: Operation,
256
+ record: TurnRecord,
257
+ shown: readonly Message[],
258
+ ): Invocation => {
259
+ const { activation, authored } = context;
260
+ const invocation: Invocation = {
261
+ operation,
262
+ calls: new Set(),
263
+ state: "running",
264
+ stopped: Promise.withResolvers(),
265
+ turn: undefined as unknown as Turn,
266
+ };
267
+ const messages = [...shown];
268
+ const { outcome: _outcome, messages: _shown, ...written } = record;
269
+ const turn: Turn = {
270
+ ...written,
271
+ messages,
272
+ send: (peer, text, options) => message(invocation, "send", peer, text, options),
273
+ reply: (peer, text, options) => message(invocation, "reply", peer, text, options),
274
+ signal: context.signal,
275
+ };
276
+ invocation.turn = turn;
277
+ let step: Promise<Step>;
278
+ if (authored?.cause) {
279
+ if (context.kind === "turn") operation.message = next(messageOperations, activation.key);
280
+ const woke = shown[0] ?? operation.woke;
281
+ operation.woke = woke;
282
+ step = messageScripts.step(
283
+ {
284
+ ...woke!,
285
+ turn,
286
+ reply: (text, options) => turn.reply(woke!.from, text, options),
287
+ send: turn.send,
288
+ silent: reply.silent,
289
+ fail: (reason) => reply.failed(reason),
290
+ } satisfies ReceivedMessage,
291
+ operation.message!,
292
+ );
293
+ } else {
294
+ if (context.kind === "turn") operation.workflow = next(workflowTurns, activation.key);
295
+ step = scripts.step(turn, operation.workflow);
296
+ }
297
+ void step.then((taken) => {
298
+ const was = invocation.state;
299
+ invocation.state = "settled";
300
+ if (was === "running") invocation.stopped.resolve({ step: taken });
301
+ else if (was === "suspended") {
302
+ failTest(
303
+ `agent "${turn.agent}" turn ${turn.n}: its script went on after its turn ended awaiting a reply; await only the host's calls`,
304
+ );
305
+ }
306
+ });
307
+ return invocation;
308
+ };
309
+
310
+ /** Runs the native turn until its script stops: an answer or ending, or a wait for a reply. */
311
+ const until = async (
312
+ invocation: Invocation,
313
+ context: FakeAdapterTurnContext,
314
+ record: TurnRecord,
315
+ ): Promise<FakeAdapterTurn> => {
316
+ const { operation } = invocation;
317
+ const stopped = await Promise.race([invocation.stopped.promise, whenAborted(context.signal)]);
318
+ if (stopped === "cancelled") {
319
+ invocation.state = "cancelled";
320
+ end(record, "cancelled");
321
+ settle(operation, (each) => each.by === invocation, { kind: "failed", reason: "cancelled" });
322
+ return {};
323
+ }
324
+ if (stopped === "suspended") {
325
+ invocation.state = "suspended";
326
+ operation.suspended = invocation;
327
+ end(record, "awaiting-reply");
328
+ return {};
329
+ }
330
+ return perform(stopped.step, context, record, invocation);
331
+ };
332
+
333
+ /**
334
+ * Lets go of scripts of `agent` suspended awaiting a reply that will not come: its call ended, as
335
+ * another of its calls starting or its session closing shows. Their waits resolve `cancelled`.
336
+ */
337
+ const abandon = (agent: string, except?: Operation) => {
338
+ for (const operation of operations.values()) {
339
+ const { suspended } = operation;
340
+ if (operation.agent !== agent || operation === except || !suspended) continue;
341
+ operation.suspended = undefined;
342
+ suspended.state = "cancelled";
343
+ settle(operation, (each) => each.by === suspended, { kind: "failed", reason: "cancelled" });
344
+ }
345
+ };
346
+
347
+ const perform = (
348
+ step: Step,
349
+ context: FakeAdapterTurnContext,
350
+ record: TurnRecord,
351
+ invocation?: Invocation,
352
+ ): FakeAdapterTurn => {
353
+ if (step.kind === "script-error") {
354
+ open.delete(record);
355
+ return failTest(step.message);
356
+ }
357
+ if (step.kind === "answer") {
358
+ return {
359
+ act: async () => {
360
+ // Submitted is answered, even though the engine then releases the turn.
361
+ let outcome: TurnOutcome = "cancelled";
362
+ try {
363
+ // As an agent's `wf send` returns before it can run `wf result`.
364
+ if (invocation) await Promise.allSettled([...invocation.calls]);
365
+ const { endpoint, operationId } = context.binding!;
366
+ const response = await submitResult(endpoint, {
367
+ version: WIRE_VERSION,
368
+ command: "result",
369
+ operationId,
370
+ raw: JSON.stringify(step.value),
371
+ });
372
+ if (response.kind === "accepted") {
373
+ outcome = "answered";
374
+ // The wait is dropped, as the agent's would be.
375
+ if (invocation) {
376
+ settle(invocation.operation, () => true, { kind: "failed", reason: "dropped" });
377
+ }
378
+ } else if (!CLOSED_CODES.has(response.code)) {
379
+ outcome = "failed";
380
+ failTest(
381
+ response.code === "owes-reply"
382
+ ? `agent "${record.agent}" turn ${record.n}: its script answered while it owes a reply; reply with turn.reply first: ${response.error}`
383
+ : `agent "${record.agent}" turn ${record.n}: the schema refused its script's answer: ${response.error}`,
384
+ );
385
+ }
386
+ } finally {
387
+ end(record, outcome);
388
+ }
389
+ },
390
+ };
391
+ }
392
+ const { ending } = step;
393
+ if (ending.kind === "waiting") {
394
+ return {
395
+ act: async () => {
396
+ const { endpoint, operationId } = context.binding!;
397
+ const response = await submitWaiting(endpoint, {
398
+ version: WIRE_VERSION,
399
+ command: "waiting",
400
+ operationId,
401
+ reason: ending.reason,
402
+ ...(ending.timeoutMs === undefined ? {} : { timeoutMs: ending.timeoutMs }),
403
+ });
404
+ if (response.kind === "rejected" && !CLOSED_CODES.has(response.code)) {
405
+ failTest(`waiting rejected: ${response.error}`);
406
+ }
407
+ end(record, response.kind === "rejected" ? "cancelled" : "waiting");
408
+ },
409
+ };
410
+ }
411
+ if (ending.kind === "hang") {
412
+ return {
413
+ act: async (turn) => {
414
+ await whenAborted(turn.signal);
415
+ end(record, "hang");
416
+ },
417
+ };
418
+ }
419
+ if (ending.kind === "interrupted") {
420
+ if (!context.activation.execution.caller) {
421
+ open.delete(record);
422
+ return failTest(
423
+ `agent "${record.agent}" turn ${record.n}: only the calling session can be interrupted by the operator`,
424
+ );
425
+ }
426
+ end(record, "interrupted");
427
+ return { state: "cancelled", detail: "interrupted by the operator" };
428
+ }
429
+ end(record, ending.kind);
430
+ return ended(ending, context.activation.execution.harness);
431
+ };
432
+
433
+ const compact = async (context: FakeAdapterTurnContext): Promise<FakeAdapterTurn> => {
434
+ const { activation } = context;
435
+ const n = (compactionCounts.get(activation.key) ?? 0) + 1;
436
+ compactionCounts.set(activation.key, n);
437
+ const record: CompactionRecord = {
438
+ agent: activation.key,
439
+ n,
440
+ id: context.id,
441
+ focus: context.prompt,
442
+ outcome: "hang",
443
+ };
444
+ compactions.push(record);
445
+ const done = (outcome: TurnOutcome) => {
446
+ record.outcome = outcome;
447
+ events.onActivity();
448
+ };
449
+ // What every real host refuses, so a test cannot pass where a run would fail.
450
+ const refused = refusedCompaction(activation, (counts.get(activation.key) ?? 0) > 0);
451
+ if (refused) {
452
+ done("failed");
453
+ return { state: "failed", detail: refused };
454
+ }
455
+ if (!compactionScripts.has(activation.key)) {
456
+ done("answered");
457
+ return { summary: "" };
458
+ }
459
+ const step = await Promise.race([
460
+ compactionScripts.step({
461
+ agent: activation.key,
462
+ n,
463
+ nudge: false,
464
+ prompt: context.prompt,
465
+ messages: [],
466
+ send: noMessages,
467
+ reply: noMessages,
468
+ cwd: activation.cwd,
469
+ model: context.settings.model,
470
+ ...(context.settings.effort === undefined ? {} : { effort: context.settings.effort }),
471
+ signal: context.signal,
472
+ }),
473
+ whenAborted(context.signal),
474
+ ]);
475
+ if (step === "cancelled") {
476
+ done("cancelled");
477
+ return {};
478
+ }
479
+ if (step.kind === "script-error") return failTest(step.message);
480
+ if (step.kind === "answer") {
481
+ if (typeof step.value !== "string") {
482
+ return failTest(
483
+ `agent "${activation.key}" compaction ${n}: its compaction script answers a summary, text, not ${JSON.stringify(step.value)}`,
484
+ );
485
+ }
486
+ done("answered");
487
+ return { summary: step.value };
488
+ }
489
+ const { ending } = step;
490
+ if (ending.kind === "waiting") return failTest("compaction cannot report waiting");
491
+ if (ending.kind === "hang") {
492
+ return {
493
+ act: async (turn) => {
494
+ await whenAborted(turn.signal);
495
+ done("hang");
496
+ },
497
+ };
498
+ }
499
+ if (ending.kind === "interrupted") {
500
+ return failTest(
501
+ `agent "${activation.key}" compaction ${n}: a compaction is not interrupted by the operator`,
502
+ );
503
+ }
504
+ done(ending.kind);
505
+ return ended(ending, activation.execution.harness);
506
+ };
507
+
508
+ const script = async (context: FakeAdapterTurnContext): Promise<FakeAdapterTurn> => {
509
+ events.onActivity();
510
+ if (context.kind === "compact") return compact(context);
511
+ const { authored, activation, binding } = context;
512
+ // The engine hands every turn and nudge what the workflow wrote, bound to its call.
513
+ if (!authored || !binding) {
514
+ return failTest(
515
+ `agent "${activation.key}" got a ${context.kind} without what the workflow wrote`,
516
+ );
517
+ }
518
+ const operation = operations.get(binding.operationId) ?? {
519
+ agent: activation.key,
520
+ binding,
521
+ awaiting: [],
522
+ };
523
+ operations.set(binding.operationId, operation);
524
+ abandon(activation.key, operation);
525
+ const n = (counts.get(activation.key) ?? 0) + (context.kind === "turn" ? 1 : 0);
526
+ counts.set(activation.key, n);
527
+ const delivered = authored.messages ?? [];
528
+ const shown = delivered.flatMap((each): Message[] =>
529
+ each.kind === "message"
530
+ ? [
531
+ {
532
+ from: each.from,
533
+ text: each.text,
534
+ ...(each.asks ? { asks: true as const } : {}),
535
+ ...(each.answers ? { answers: true as const } : {}),
536
+ },
537
+ ]
538
+ : [],
539
+ );
540
+ const record: TurnRecord = {
541
+ agent: activation.key,
542
+ n,
543
+ // A continuation that delivers nothing is a reminder of a reply owed: a nudge.
544
+ nudge: context.kind === "nudge" || (context.kind === "deliver" && delivered.length === 0),
545
+ prompt: authored.prompt,
546
+ ...(authored.schema === undefined ? {} : { schema: authored.schema }),
547
+ ...(authored.label === undefined ? {} : { label: authored.label }),
548
+ ...(authored.stage === undefined ? {} : { stage: authored.stage }),
549
+ cwd: activation.cwd,
550
+ model: context.settings.model,
551
+ ...(context.settings.effort === undefined ? {} : { effort: context.settings.effort }),
552
+ ...(authored.cause === undefined ? {} : { cause: authored.cause }),
553
+ ...(shown.length === 0 ? {} : { messages: shown }),
554
+ outcome: "hang",
555
+ };
556
+ turns.push(record);
557
+ open.add(record);
558
+
559
+ // A reply or a failure settles the send awaiting it, and resumes the script that awaits it.
560
+ const settled: [Awaiting, MessageOutcome][] = [];
561
+ for (const each of delivered) {
562
+ if (each.kind === "message" && !each.answers) continue;
563
+ const at = operation.awaiting.findIndex((awaiting) => awaiting.peer === each.from);
564
+ if (at === -1) continue;
565
+ const [awaiting] = operation.awaiting.splice(at, 1);
566
+ settled.push([
567
+ awaiting!,
568
+ each.kind === "message"
569
+ ? { kind: "replied", text: each.text }
570
+ : { kind: "failed", reason: each.reason },
571
+ ]);
572
+ }
573
+ const { suspended } = operation;
574
+ if (suspended) {
575
+ if (!settled.some(([awaiting]) => awaiting.by === suspended)) {
576
+ open.delete(record);
577
+ return failTest(
578
+ `agent "${activation.key}" turn ${n}: the test host cannot script this: its script awaits a reply, and a turn came without it: ${
579
+ shown.find((each) => each.asks)
580
+ ? `${shown.find((each) => each.asks)!.from} asked it a question`
581
+ : "it was reminded of a reply it owes"
582
+ }; a script that awaits does nothing until its reply comes`,
583
+ );
584
+ }
585
+ operation.suspended = undefined;
586
+ suspended.state = "running";
587
+ suspended.stopped = Promise.withResolvers();
588
+ (suspended.turn.messages as Message[]).push(...shown);
589
+ for (const [awaiting, outcome] of settled) awaiting.resolve(outcome);
590
+ return until(suspended, context, record);
591
+ }
592
+ for (const [awaiting, outcome] of settled) awaiting.resolve(outcome);
593
+ if (authored.cause && shown.length === 0 && operation.woke === undefined) {
594
+ open.delete(record);
595
+ return failTest(`agent "${activation.key}" turn ${n}: woken with no message`);
596
+ }
597
+ return until(invoke(context, operation, record, shown), context, record);
598
+ };
599
+
600
+ const side = (placement: AgentPlacement) =>
601
+ createSingleSessionHostFactory(
602
+ createFakeAdapter({
603
+ harnesses: PLACEMENT_HARNESSES[placement],
604
+ supportsWaiting: (activation) =>
605
+ placement === "pane" &&
606
+ activation.execution.harness === "claude" &&
607
+ !activation.execution.caller,
608
+ placement,
609
+ launchesInSandbox: true,
610
+ givesSkills: true,
611
+ refuse: (activation: HarnessActivation) =>
612
+ placement === "headless" ? headlessRefusal(activation) : paneRefusal(activation),
613
+ // As the real hosts: each continues a forked session, and forks where the harness can.
614
+ continues: true as const,
615
+ forks: (activation: HarnessActivation) =>
616
+ findHarness(activation.execution.harness)?.forkSession !== undefined,
617
+ onFork: ({ activation, sessionRef }: FakeFork) =>
618
+ forks.set(sessionRef, {
619
+ key: activation.key,
620
+ turns: counts.get(activation.key) ?? 0,
621
+ }),
622
+ // As the real hosts: each switches where the harness can, in its placement.
623
+ sets: (activation: HarnessActivation) =>
624
+ settingsRefusal(activation.execution) === undefined,
625
+ onSet: ({ activation, settings }: FakeSet) => {
626
+ sets.push({
627
+ agent: activation.key,
628
+ n: sets.filter((set) => set.agent === activation.key).length + 1,
629
+ ...settings,
630
+ });
631
+ events.onActivity();
632
+ return undefined;
633
+ },
634
+ script,
635
+ }),
636
+ );
637
+ const placed = createPlacementHostFactory({
638
+ pane: side("pane"),
639
+ headless: side("headless"),
640
+ ...(caller && caller.here !== false
641
+ ? {
642
+ caller: {
643
+ caller: { harness: caller.harness, cwd: caller.cwd },
644
+ calling,
645
+ openRun: (spec) =>
646
+ createSingleSessionHostFactory(
647
+ createFakeAdapter({
648
+ harnesses: [caller.harness],
649
+ placement: "pane",
650
+ script: (context) => {
651
+ callerKey = context.activation.key;
652
+ return script(context);
653
+ },
654
+ ...(typeof calling === "object" ? { found: calling } : {}),
655
+ }),
656
+ ).openRun(spec),
657
+ },
658
+ }
659
+ : calling
660
+ ? { calling }
661
+ : {}),
662
+ });
663
+
664
+ return {
665
+ factory: {
666
+ ...(placed.caller ? { caller: placed.caller } : {}),
667
+ ...(placed.calling ? { calling: placed.calling } : {}),
668
+ async openRun(spec) {
669
+ const run = await placed.openRun(spec);
670
+ return {
671
+ inspect: () => run.inspect(),
672
+ close: (reason) => run.close(reason),
673
+ async openAgent(request) {
674
+ const session = await run.openAgent(request);
675
+ const opened: OpenedAgent = {
676
+ key: request.key,
677
+ execution: request.execution,
678
+ ...(request.instructions ? { instructions: request.instructions } : {}),
679
+ ...(request.labels ? { labels: request.labels } : {}),
680
+ ...(request.skills ? { skills: request.skills.names } : {}),
681
+ ...(request.continues && forks.has(request.continues.sessionRef)
682
+ ? { forkedFrom: forks.get(request.continues.sessionRef)! }
683
+ : {}),
684
+ ...(request.layout ? { layout: request.layout } : {}),
685
+ ...(request.layoutFallback ? { layoutFallback: request.layoutFallback } : {}),
686
+ ...(request.keepPane ? { keepPane: request.keepPane } : {}),
687
+ };
688
+ agents.push(opened);
689
+ return {
690
+ ...session,
691
+ close(reason, options) {
692
+ if (options?.keep) opened.keptPane = true;
693
+ abandon(request.key);
694
+ return session.close(reason, options);
695
+ },
696
+ };
697
+ },
698
+ };
699
+ },
700
+ },
701
+ turns,
702
+ compactions,
703
+ sets,
704
+ agents,
705
+ inFlight: () => [...open],
706
+ };
707
+ }
708
+
709
+ /** A call's state across its turns: the sends its scripts await, and a script awaiting one. */
710
+ type Operation = {
711
+ agent: string;
712
+ binding: NonNullable<FakeAdapterTurnContext["binding"]>;
713
+ /** Sends awaiting a reply, oldest first. */
714
+ awaiting: Awaiting[];
715
+ /** The script whose turn ended awaiting a reply, which the turn that carries it resumes. */
716
+ suspended?: Invocation;
717
+ /** The call's place among its agent's workflow turns, or among its message operations. */
718
+ workflow?: number;
719
+ message?: number;
720
+ /** The message a message operation last ran its script with, for a reminder. */
721
+ woke?: Message;
722
+ };
723
+
724
+ type Awaiting = { peer: string; by: Invocation; resolve(outcome: MessageOutcome): void };
725
+
726
+ /** One run of a script, which may outlast its native turn while it awaits a reply. */
727
+ type Invocation = {
728
+ operation: Operation;
729
+ turn: Turn;
730
+ /** Its `wf` calls in flight. */
731
+ calls: Set<Promise<unknown>>;
732
+ state: "running" | "suspended" | "settled" | "cancelled";
733
+ /** Where it stopped in its current native turn. */
734
+ stopped: PromiseWithResolvers<"suspended" | { step: Step }>;
735
+ };
736
+
737
+ const MESSAGE_CODES = new Set<string>([...SEND_CODES, ...REPLY_CODES]);
738
+ /** The call ended before the script's answer reached it: the turn was let go, not refused. */
739
+ const CLOSED_CODES = new Set<string>([
740
+ "closed-operation",
741
+ "expired-operation",
742
+ "unknown-operation",
743
+ ]);
744
+
745
+ function settle(
746
+ operation: Operation,
747
+ which: (awaiting: Awaiting) => boolean,
748
+ outcome: MessageOutcome,
749
+ ): void {
750
+ const settling = operation.awaiting.filter(which);
751
+ operation.awaiting = operation.awaiting.filter((each) => !which(each));
752
+ for (const awaiting of settling) awaiting.resolve(outcome);
753
+ }
754
+
755
+ async function noMessages(): Promise<MessageOutcome> {
756
+ throw new Error("a compaction sends no messages");
757
+ }
758
+
759
+ /** What the pane host refuses before it opens a pane. */
760
+ function paneRefusal(activation: HarnessActivation): string | undefined {
761
+ const { harness } = activation.execution;
762
+ const spec = findHarness(harness);
763
+ if (activation.continues && spec && !spec.interactiveResume) {
764
+ return `${harness} panes cannot continue a forked session: ${spec.absent.interactiveResume}`;
765
+ }
766
+ return undefined;
767
+ }
768
+
769
+ function whenAborted(signal: AbortSignal): Promise<"cancelled"> {
770
+ return new Promise((resolve) => {
771
+ if (signal.aborted) resolve("cancelled");
772
+ else signal.addEventListener("abort", () => resolve("cancelled"), { once: true });
773
+ });
774
+ }
775
+
776
+ function refusedCompaction(activation: HarnessActivation, hasRun: boolean): string | undefined {
777
+ if (activation.execution.caller) {
778
+ return "the calling session's context is the operator's, so a run does not compact it";
779
+ }
780
+ const spec = findHarness(activation.execution.harness);
781
+ const capability =
782
+ placementOf(activation.execution) === "pane" ? "compactPane" : "compactHeadless";
783
+ if (spec && !spec[capability]) {
784
+ return `${activation.execution.harness} has no compaction of its own: ${spec.absent[capability]}`;
785
+ }
786
+ if (!hasRun) return "there is nothing to compact before the first turn";
787
+ return undefined;
788
+ }
789
+
790
+ type Ended = Exclude<
791
+ Extract<Step, { kind: "reply" }>["ending"],
792
+ { kind: "waiting" | "hang" | "interrupted" }
793
+ >;
794
+
795
+ /** A scripted ending as the agent's harness reports it. */
796
+ function ended(ending: Ended, harness: string): FakeAdapterTurn {
797
+ if (ending.kind === "silent") return {};
798
+ if (ending.kind !== "needs-login") return { state: ending.kind, detail: ending.reason };
799
+ const check = findHarness(harness)?.login;
800
+ const provider = ending.provider === undefined ? {} : { provider: ending.provider };
801
+ return check
802
+ ? {
803
+ state: "failed",
804
+ ...loginFailure(harness, check, { ...provider, said: "reply.needsLogin()" }),
805
+ }
806
+ : {
807
+ state: "failed",
808
+ detail: `${harness} needs a login`,
809
+ login: { harness, ...provider, run: "log in" },
810
+ };
811
+ }