@effect-agent/testing 0.0.1-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.
@@ -0,0 +1,112 @@
1
+ import { Schema } from "effect";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Phase 7 (P7): the Travel Planner as an INTERNAL agent with two explicit
5
+ // profiles (ROADMAP P7 exit gate: "Travel Planner ... retains a deterministic
6
+ // offline conformance profile alongside its live integration profiles").
7
+ //
8
+ // - The OFFLINE profile is the cumulative P1–P6/S1/S2 suite set that already
9
+ // exists: scripted `LanguageModel`, deterministic supplier Layers,
10
+ // controllable time and IDs, no network, no credentials. Nothing in P7
11
+ // weakens it; this module only pins the claim as a Schema value so evidence
12
+ // can cite one committed fact instead of prose.
13
+ // - The LIVE profile is opt-in and test-side: a suite gates itself with
14
+ // `describe.skipIf(...)` on the environment predicate below, so ordinary
15
+ // `bun run test` never makes a network request and never needs a credential.
16
+ // Live profiles bind live MODEL Layers only. No real travel supplier exists
17
+ // to integrate, so the deterministic supplier desk is retained deliberately
18
+ // and `liveSupplierLayers` is pinned `false` — the roadmap's "selected
19
+ // supplier Layers" is honestly scoped to live-model-only (P7 plan decision
20
+ // 9), recorded here rather than silently claimed.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * The P7 dual-profile claim, schema-first so the exact scope of "live
25
+ * integration profiles" is a committed, decodable value:
26
+ *
27
+ * - `offlineConformanceDeterministic` / `offlineRequiresCredentials`: the
28
+ * cumulative conformance suites stay deterministic and credential-free.
29
+ * - `liveProfileOptIn`: live suites are excluded from ordinary gates by the
30
+ * environment predicate (`phase7LiveProfileEnabled`), never by test-runner
31
+ * configuration that could silently drift.
32
+ * - `liveModelLayers` / `liveSupplierLayers`: live profiles exercise real
33
+ * model Layers over the SAME deterministic supplier desk — no claim of a
34
+ * live supplier integration is made anywhere (decision 9).
35
+ * - `structurallyRedactedTranscripts`: transcript evidence a live profile
36
+ * emits passes through the structural `Redactor` first (SEC-008,
37
+ * testing.md §12: "live model and supplier profiles are opt-in smoke or
38
+ * release tests, rate-limited and structurally redacted").
39
+ * - `exactlyOnceExternalEffects`: never claimed at any phase (DUR-003).
40
+ */
41
+ export class TravelPlannerPhase7Profile extends Schema.Class<TravelPlannerPhase7Profile>(
42
+ "@effect-agent/testing/travel-planner/TravelPlannerPhase7Profile",
43
+ )({
44
+ phase: Schema.Literal("P7"),
45
+ offlineConformanceDeterministic: Schema.Literal(true),
46
+ offlineRequiresCredentials: Schema.Literal(false),
47
+ liveProfileOptIn: Schema.Literal(true),
48
+ liveModelLayers: Schema.Literal(true),
49
+ liveSupplierLayers: Schema.Literal(false),
50
+ structurallyRedactedTranscripts: Schema.Literal(true),
51
+ exactlyOnceExternalEffects: Schema.Literal(false),
52
+ }) {}
53
+
54
+ export const phase7TravelPlannerProfile = TravelPlannerPhase7Profile.make({
55
+ phase: "P7",
56
+ offlineConformanceDeterministic: true,
57
+ offlineRequiresCredentials: false,
58
+ liveProfileOptIn: true,
59
+ liveModelLayers: true,
60
+ liveSupplierLayers: false,
61
+ structurallyRedactedTranscripts: true,
62
+ exactlyOnceExternalEffects: false,
63
+ });
64
+
65
+ /**
66
+ * The one opt-in switch for EVERY live profile in this repository. `"1"` is
67
+ * the only enabling value: an unset, empty, or differently-truthy value keeps
68
+ * the suite skipped, so CI and ordinary developer runs stay offline.
69
+ */
70
+ export const PHASE7_LIVE_GATE_ENV = "EFFECT_AGENT_LIVE";
71
+
72
+ /** The credential a Travel Planner live-model profile additionally requires. */
73
+ export const PHASE7_LIVE_CREDENTIAL_ENV = "OPENAI_API_KEY";
74
+
75
+ /** Structural shape of `process.env` without importing Node types here. */
76
+ export interface Phase7LiveGateEnvironment {
77
+ readonly [name: string]: string | undefined;
78
+ }
79
+
80
+ /**
81
+ * The test-side live gate (P7 plan §6: no test-side live-gating pattern
82
+ * existed before this — the demo gates at serve time via
83
+ * `Config.redacted("OPENAI_API_KEY")`). Suites use it as
84
+ * `describe.skipIf(!phase7LiveProfileEnabled(process.env))`, which keeps the
85
+ * live block out of ordinary gates while the SAME file's ungated tests keep
86
+ * pinning the profile schema on every run.
87
+ */
88
+ export const phase7LiveProfileEnabled = (env: Phase7LiveGateEnvironment): boolean =>
89
+ env[PHASE7_LIVE_GATE_ENV] === "1" && (env[PHASE7_LIVE_CREDENTIAL_ENV] ?? "") !== "";
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Authoring friction note (WP7 input; real observations from wiring the P7
93
+ // live profile onto the existing Travel Planner):
94
+ //
95
+ // 1. There was no framework-owned place to put a test-side live gate: every
96
+ // earlier profile was either always-deterministic or gated at serve time
97
+ // inside an application. The predicate had to be invented here as a plain
98
+ // exported function because `packages/testing/src` must stay
99
+ // platform-neutral and cannot read `process.env` itself — fine, but the
100
+ // split (fixture exports the predicate, the example test applies it to
101
+ // `process.env`) is a convention a future author has to discover by
102
+ // reading this file rather than a typed seam.
103
+ // 2. Capturing a structurally redacted transcript from a live Run takes
104
+ // manual assembly: collect `RunEvent`s, `Schema.encode` each one, pass the
105
+ // encoded value through `Redactor.redact`. Nothing composes those three
106
+ // steps, and nothing type-level stops a test from logging the RAW event by
107
+ // accident. A `redactedTranscript(events)` helper in capabilities (or an
108
+ // engine stream combinator) would make the safe path the short path.
109
+ // 3. Binding a live model to the existing definition was pleasantly trivial
110
+ // (`Agent.withModel` + the upstream client Layer): no friction to report
111
+ // on the authoring surface itself for plain ephemeral runs.
112
+ // ---------------------------------------------------------------------------
@@ -0,0 +1,85 @@
1
+ import { Schema } from "effect";
2
+
3
+ import type { ScriptedTurnInput } from "../../scripted-model.ts";
4
+ import { TravelPlan, TripRequest, type TravelPlan as TravelPlanValue } from "./definition.ts";
5
+
6
+ const usage = { inputTokens: { total: 128 }, outputTokens: { total: 96 } };
7
+ export const phase1Trip = Schema.decodeSync(TripRequest)({
8
+ request:
9
+ "Plan a review-only London trip using the deterministic flight, lodging, and activity searches.",
10
+ origin: "SFO",
11
+ destination: "LHR",
12
+ departOn: "2026-09-14",
13
+ nights: 4,
14
+ travelers: 2,
15
+ budgetCents: 350_000,
16
+ currency: "USD",
17
+ });
18
+ /** Backward-compatible fixture alias while consumers transition to the P1 name. */
19
+ export const phase0Trip = phase1Trip;
20
+ export const expectedTravelPlan: TravelPlanValue = Schema.decodeSync(TravelPlan)({
21
+ itineraries: [
22
+ {
23
+ title: "Westward light, eastbound overnight",
24
+ route: "San Francisco → London",
25
+ dates: "14–19 September 2026",
26
+ flight: "EA 218 · nonstop · SFO 18:40 → LHR 13:05+1",
27
+ lodging: "Bloomsbury House · refundable studio · 4 nights",
28
+ activities: ["British Museum timed entry", "Thames evening walk"],
29
+ estimatedTotalCents: 284_000,
30
+ currency: "USD",
31
+ quoteId: "quote-sfo-lhr-001",
32
+ assumptions: [
33
+ "Two travelers sharing one studio",
34
+ "Quote is read-only availability, not a reservation",
35
+ ],
36
+ unresolvedConstraints: [
37
+ "Traveler names and accessibility requests are intentionally omitted",
38
+ ],
39
+ nextAction: "review",
40
+ },
41
+ ],
42
+ });
43
+
44
+ export const phase1HappyPathTurns = [
45
+ {
46
+ _tag: "Stream",
47
+ parts: [
48
+ {
49
+ type: "tool-call",
50
+ id: "flight-call-1",
51
+ name: "search_flights",
52
+ params: { origin: "SFO", destination: "LHR", departOn: "2026-09-14", travelers: 2 },
53
+ },
54
+ {
55
+ type: "tool-call",
56
+ id: "lodging-call-1",
57
+ name: "search_lodging",
58
+ params: { destination: "LHR", departOn: "2026-09-14", nights: 4, travelers: 2 },
59
+ },
60
+ {
61
+ type: "tool-call",
62
+ id: "activity-call-1",
63
+ name: "search_activities",
64
+ params: { destination: "LHR", departOn: "2026-09-14", nights: 4, travelers: 2 },
65
+ },
66
+ { type: "finish", reason: "tool-calls", usage },
67
+ ],
68
+ termination: { _tag: "Complete" },
69
+ },
70
+ {
71
+ _tag: "Stream",
72
+ parts: [
73
+ { type: "text-start", id: "itinerary-json" },
74
+ {
75
+ type: "text-delta",
76
+ id: "itinerary-json",
77
+ delta: JSON.stringify(Schema.encodeSync(TravelPlan)(expectedTravelPlan)),
78
+ },
79
+ { type: "text-end", id: "itinerary-json" },
80
+ { type: "finish", reason: "stop", usage },
81
+ ],
82
+ termination: { _tag: "Complete" },
83
+ },
84
+ ] satisfies readonly [ScriptedTurnInput, ScriptedTurnInput];
85
+ export const phase0HappyPathTurns = phase1HappyPathTurns;
@@ -0,0 +1,391 @@
1
+ import {
2
+ delegationAllocationFromPolicy,
3
+ SubagentReservationsMemoryLive,
4
+ SubagentRuntime,
5
+ } from "@effect-agent/capabilities";
6
+ import { Agent, type ConversationId } from "@effect-agent/core";
7
+ import type { RuntimeBinding } from "@effect-agent/engine";
8
+ import {
9
+ DefinitionDigests,
10
+ DeploymentId,
11
+ Digest,
12
+ DurableWorkerBinding,
13
+ Principal,
14
+ ProducerId,
15
+ type DurableSubmitOptions,
16
+ type IdempotencyKey,
17
+ type ResolvedBinding,
18
+ } from "@effect-agent/session";
19
+ import { Effect, Layer, Ref, Schema, Stream } from "effect";
20
+ import { LanguageModel, Model, type Response, type Toolkit } from "effect/unstable/ai";
21
+
22
+ import { DeterministicIdGeneratorLayer } from "./deterministic-layers.ts";
23
+ import {
24
+ DestinationBrief,
25
+ DestinationFacts,
26
+ DestinationGuide,
27
+ DestinationRecommendation,
28
+ DestinationReport,
29
+ DestinationResearcher,
30
+ DestinationResearcherToolkit,
31
+ DestinationResearcherToolkitLayer,
32
+ destinationLookup,
33
+ destinationReportFor,
34
+ destinationResearchDelegation,
35
+ destinationResearchPolicy,
36
+ DestinationShortlist,
37
+ encodedDestinationReport,
38
+ mapResearchChildFailure,
39
+ ResearchDispatchGate,
40
+ TravelCoordinator,
41
+ } from "./subagents.ts";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // S2 durability profile (spec/subagents.md §17): the S1 coordinator →
45
+ // destination-researcher delegation re-run as ACCEPTED WORK on the Node/SQLite
46
+ // runtime. The claim is `DN` durable attached Subagents only: establishment
47
+ // and join are replay-safe by construction (SUB-016/SUB-019), a completed
48
+ // child is never re-executed on a lost join acknowledgment, and NO claim of
49
+ // exactly-once child external effects is made (rule 8; an unresolved ordinary
50
+ // child Tool blocks as an Unknown Outcome instead, SUB-021). Cloudflare
51
+ // equivalence is P6 scope and explicitly not claimed here.
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export class TravelPlannerSubagentDurabilityProfile extends Schema.Class<TravelPlannerSubagentDurabilityProfile>(
55
+ "@effect-agent/testing/travel-planner/TravelPlannerSubagentDurabilityProfile",
56
+ )({
57
+ deploymentClass: Schema.Literal("DN"),
58
+ durableAttachedSubagents: Schema.Literal(true),
59
+ canonicalSchemaVersion: Schema.Literal(1),
60
+ /** Establishment/join replay converges on one child Receipt, Conversation, and join batch. */
61
+ subagentReplaySafe: Schema.Literal(true),
62
+ /** Never claimed (rule 8): child ordinary Tools stop at Unknown Outcomes, they do not replay. */
63
+ childExternalEffectsExactlyOnce: Schema.Literal(false),
64
+ /** The same conformance suite under DO eviction/alarms is P6 scope (spec §17 `DC`). */
65
+ cloudflareEquivalence: Schema.Literal(false),
66
+ }) {}
67
+
68
+ export const s2TravelPlannerProfile = TravelPlannerSubagentDurabilityProfile.make({
69
+ deploymentClass: "DN",
70
+ durableAttachedSubagents: true,
71
+ canonicalSchemaVersion: 1,
72
+ subagentReplaySafe: true,
73
+ childExternalEffectsExactlyOnce: false,
74
+ cloudflareEquivalence: false,
75
+ });
76
+
77
+ export const s2TravelPlannerDeploymentId = Schema.decodeSync(DeploymentId)(
78
+ "travel-planner-s2-deployment",
79
+ );
80
+ export const s2TravelPlannerProducerId = Schema.decodeSync(ProducerId)(
81
+ "travel-planner-s2-producer",
82
+ );
83
+ export const s2TravelPlannerPrincipal = Schema.decodeSync(Principal)("travel-planner-s2-principal");
84
+
85
+ const digestOf = (character: string) => Schema.decodeSync(Digest)(character.repeat(64));
86
+
87
+ /** Redacted, deterministic parent (coordinator) definition digests for this fixture version. */
88
+ export const s2CoordinatorDigests = DefinitionDigests.make({
89
+ agent: digestOf("a"),
90
+ model: digestOf("b"),
91
+ tools: digestOf("c"),
92
+ });
93
+
94
+ /**
95
+ * The exact child Binding digest strings the application declares on
96
+ * `SubagentRuntimeOptions.durable.targetDigests` AND the host registers with
97
+ * the `AgentBindingResolver` for the researcher Binding. The coordinator
98
+ * stores and verifies them byte-for-byte (SUB-023); a host registration under
99
+ * different strings is a `ChildCompatibilityFailure`, never a substitution.
100
+ */
101
+ export const s2ResearcherDigestStrings = {
102
+ agent: "d".repeat(64),
103
+ model: "e".repeat(64),
104
+ tools: "f".repeat(64),
105
+ } as const;
106
+
107
+ export const s2ResearcherDigests = DefinitionDigests.make({
108
+ agent: Schema.decodeSync(Digest)(s2ResearcherDigestStrings.agent),
109
+ model: Schema.decodeSync(Digest)(s2ResearcherDigestStrings.model),
110
+ tools: Schema.decodeSync(Digest)(s2ResearcherDigestStrings.tools),
111
+ });
112
+
113
+ /** Durable admission options for one coordinator Submission on one mission lane. */
114
+ export const s2TravelPlannerSubmitOptions = (
115
+ conversationId: ConversationId,
116
+ idempotencyKey: IdempotencyKey,
117
+ ): DurableSubmitOptions => ({
118
+ conversationId,
119
+ principal: s2TravelPlannerPrincipal,
120
+ idempotencyKey,
121
+ definitions: s2CoordinatorDigests,
122
+ });
123
+
124
+ /** The structural submit slice of the coordinator Binding (`DurableAgentRuntime.submit`). */
125
+ export const s2CoordinatorSubmitAgent = {
126
+ definition: { id: TravelCoordinator.id, input: TravelCoordinator.input },
127
+ } as const;
128
+
129
+ /**
130
+ * The per-invocation reservation the durable handler computes from the S1
131
+ * delegation policy (`delegationAllocationFromPolicy`): the conservation
132
+ * evidence in the S2 tests checks the ledger reservation rows and the
133
+ * canonical `SubagentJoined.finalAccounting` against exactly this value.
134
+ */
135
+ export const durableResearchAllocation = delegationAllocationFromPolicy(destinationResearchPolicy);
136
+
137
+ /** The one scripted delegation Tool Call id of the durable coordinator Run. */
138
+ export const durableResearchCallId = "research-lhr-1";
139
+
140
+ /** The child's own scripted guide-lookup Tool Call id. */
141
+ export const durableChildLookupCallId = (destination: string): string => `lookup-${destination}`;
142
+
143
+ /** The projected finding the parent joins (only the advisory crosses, SUB-015). */
144
+ export const durableResearchFinding = (destination: string) => ({
145
+ destination: destinationReportFor(destination).destination,
146
+ summary: destinationReportFor(destination).advisory,
147
+ });
148
+
149
+ /** The coordinator's expected final shortlist for one researched destination. */
150
+ export const durableResearchShortlist = (destination: string): DestinationShortlist =>
151
+ DestinationShortlist.make({
152
+ recommendations: [
153
+ DestinationRecommendation.make({
154
+ destination: destinationReportFor(destination).destination,
155
+ summary: destinationReportFor(destination).advisory,
156
+ }),
157
+ ],
158
+ nextAction: "review",
159
+ });
160
+
161
+ /**
162
+ * The deterministic guide facts in encoded (wire) form: the "supplier truth"
163
+ * an authorized operator records through `resolveUnknown` when a child guide
164
+ * lookup stopped at an Unknown Outcome (DUR-017 — the framework never guesses
165
+ * or replays it).
166
+ */
167
+ export const encodedDestinationFacts = (destination: string): unknown => {
168
+ const report = destinationReportFor(destination);
169
+ return Schema.encodeSync(DestinationFacts)(
170
+ DestinationFacts.make({
171
+ destination: report.destination,
172
+ highlights: report.highlights,
173
+ advisory: report.advisory,
174
+ }),
175
+ );
176
+ };
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Invocation-counting scripted models (the P5 SupplierBookingDesk counter
180
+ // pattern): the call counter and captured prompts live OUTSIDE the Model
181
+ // Layer, so they survive Layer rebuilds across Attempts and across separate
182
+ // runtime handles over the same SQLite file — "the child was never
183
+ // re-executed" is asserted, not assumed.
184
+ // ---------------------------------------------------------------------------
185
+
186
+ const scriptedUsage = { inputTokens: { total: 96 }, outputTokens: { total: 64 } };
187
+
188
+ /** One scripted model whose behavior is keyed by the global invocation index. */
189
+ export const makeInvocationCountingModel = (
190
+ name: string,
191
+ script: (call: number) => ReadonlyArray<Response.StreamPartEncoded>,
192
+ ) =>
193
+ Effect.gen(function* () {
194
+ const calls = yield* Ref.make(0);
195
+ const prompts = yield* Ref.make<ReadonlyArray<string>>([]);
196
+ const model = Model.make(
197
+ "scripted",
198
+ name,
199
+ Layer.effect(
200
+ LanguageModel.LanguageModel,
201
+ LanguageModel.make({
202
+ generateText: () => Effect.succeed([]),
203
+ streamText: (request) =>
204
+ Stream.unwrap(
205
+ Effect.gen(function* () {
206
+ const call = yield* Ref.getAndUpdate(calls, (value) => value + 1);
207
+ yield* Ref.update(prompts, (previous) => [
208
+ ...previous,
209
+ JSON.stringify(request.prompt.content),
210
+ ]);
211
+ return Stream.fromIterable(script(call));
212
+ }),
213
+ ),
214
+ }),
215
+ ),
216
+ );
217
+ return { model, calls: Ref.get(calls), prompts: Ref.get(prompts) };
218
+ });
219
+
220
+ const delegationTurnParts = (
221
+ toolCallId: string,
222
+ destination: string,
223
+ focus: string,
224
+ ): ReadonlyArray<Response.StreamPartEncoded> => [
225
+ {
226
+ type: "tool-call",
227
+ id: toolCallId,
228
+ name: "delegate_destination_research",
229
+ params: { destination, focus },
230
+ providerExecuted: false,
231
+ },
232
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
233
+ ];
234
+
235
+ const shortlistParts = (
236
+ shortlist: DestinationShortlist,
237
+ ): ReadonlyArray<Response.StreamPartEncoded> => [
238
+ { type: "text-start", id: "shortlist" },
239
+ {
240
+ type: "text-delta",
241
+ id: "shortlist",
242
+ delta: JSON.stringify(Schema.encodeSync(DestinationShortlist)(shortlist)),
243
+ },
244
+ { type: "text-end", id: "shortlist" },
245
+ { type: "finish", reason: "stop", usage: scriptedUsage },
246
+ ];
247
+
248
+ const researcherLookupParts = (destination: string): ReadonlyArray<Response.StreamPartEncoded> => [
249
+ {
250
+ type: "tool-call",
251
+ id: durableChildLookupCallId(destination),
252
+ name: "lookup_destination",
253
+ params: { destination },
254
+ providerExecuted: false,
255
+ },
256
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
257
+ ];
258
+
259
+ const researcherReportParts = (destination: string): ReadonlyArray<Response.StreamPartEncoded> => [
260
+ { type: "text-start", id: "destination-report" },
261
+ { type: "text-delta", id: "destination-report", delta: encodedDestinationReport(destination) },
262
+ { type: "text-end", id: "destination-report" },
263
+ { type: "finish", reason: "stop", usage: scriptedUsage },
264
+ ];
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Durable delegation wiring (S2): the SAME immutable S1 Delegation Definition
268
+ // paired with one explicit child Binding, now carrying the construction-fixed
269
+ // durable declaration. Under a durable coordinator the handler establishes an
270
+ // accepted-work child instead of spawning an in-process fiber; without the
271
+ // declaration a durable-mode invocation fails closed (WP5 contract).
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /** Runtime wiring for the durable slice: the S1 delegation plus the S2 digest declaration. */
275
+ export const durableDestinationResearchHandlersLayer = <Provider, ModelProvides, ModelRequires>(
276
+ childBinding: RuntimeBinding<
277
+ typeof DestinationBrief,
278
+ typeof DestinationReport,
279
+ string,
280
+ Toolkit.Tools<typeof DestinationResearcherToolkit>,
281
+ Provider,
282
+ ModelProvides,
283
+ ModelRequires
284
+ >,
285
+ ) =>
286
+ SubagentRuntime.layer(destinationResearchDelegation, childBinding, {
287
+ mapChildFailure: mapResearchChildFailure,
288
+ durable: { targetDigests: s2ResearcherDigestStrings },
289
+ });
290
+
291
+ /** Optional overrides for one durable research harness. */
292
+ export interface DurableResearchHarnessOptions {
293
+ /** Researched destination; defaults to "LHR". */
294
+ readonly destination?: string | undefined;
295
+ /** Delegation focus; defaults to "museums". */
296
+ readonly focus?: string | undefined;
297
+ /**
298
+ * Digests the HOST registers the child Binding under; defaults to the exact
299
+ * declared `s2ResearcherDigests`. Register different digests to force the
300
+ * fail-closed `ChildCompatibilityFailure` path (SUB-023/SUB-032).
301
+ */
302
+ readonly childRegistrationDigests?: DefinitionDigests | undefined;
303
+ }
304
+
305
+ /** One durable coordinator/researcher pair with observable invocation counters. */
306
+ export interface DurableResearchHarness {
307
+ /** Host registrations for `NodeDurableRuntimeOptions.bindings` (parent + child). */
308
+ readonly bindings: ReadonlyArray<ResolvedBinding>;
309
+ /** Total coordinator model invocations across every Attempt and runtime handle. */
310
+ readonly parentModelCalls: Effect.Effect<number>;
311
+ /** JSON-encoded coordinator prompts in request order. */
312
+ readonly parentPrompts: Effect.Effect<ReadonlyArray<string>>;
313
+ /** Total researcher model invocations across every Attempt and runtime handle. */
314
+ readonly childModelCalls: Effect.Effect<number>;
315
+ /** JSON-encoded researcher prompts in request order (context-isolation evidence). */
316
+ readonly childPrompts: Effect.Effect<ReadonlyArray<string>>;
317
+ /** Deterministic guide-lookup handler executions (ordinary child Tool side effects). */
318
+ readonly guideInvocations: Effect.Effect<number>;
319
+ }
320
+
321
+ /**
322
+ * Build the S2 Travel Planner harness: an invocation-counting scripted
323
+ * coordinator (Turn 1 declares the one delegation call, Turn 2 writes the
324
+ * shortlist), an invocation-counting scripted researcher (Turn 1 consults the
325
+ * guide, Turn 2 writes the report), and both worker Bindings captured with
326
+ * their requirement Contexts via `DurableWorkerBinding.make` under the exact
327
+ * fixture digests. The returned `bindings` are plain values: they can be
328
+ * registered with several `NodeDurableRuntime` stacks over the same SQLite
329
+ * file while the counters keep counting across all of them.
330
+ */
331
+ export const makeDurableResearchHarness = (options?: DurableResearchHarnessOptions) =>
332
+ Effect.gen(function* () {
333
+ const destination = options?.destination ?? "LHR";
334
+ const focus = options?.focus ?? "museums";
335
+
336
+ const guideInvocations = yield* Ref.make(0);
337
+ const guideLayer = Layer.succeed(
338
+ DestinationGuide,
339
+ DestinationGuide.of({
340
+ lookup: (query) =>
341
+ Ref.update(guideInvocations, (count) => count + 1).pipe(
342
+ Effect.andThen(destinationLookup(query)),
343
+ ),
344
+ }),
345
+ );
346
+ const childToolkitLayer = DestinationResearcherToolkitLayer.pipe(
347
+ Layer.provideMerge(guideLayer),
348
+ );
349
+
350
+ const childModel = yield* makeInvocationCountingModel("destination-researcher-s2", (call) =>
351
+ call === 0 ? researcherLookupParts(destination) : researcherReportParts(destination),
352
+ );
353
+ const childBinding = Agent.withModel(DestinationResearcher, childModel.model);
354
+
355
+ const parentModel = yield* makeInvocationCountingModel("travel-coordinator-s2", (call) =>
356
+ call === 0
357
+ ? delegationTurnParts(durableResearchCallId, destination, focus)
358
+ : shortlistParts(durableResearchShortlist(destination)),
359
+ );
360
+ const parentBinding = Agent.withModel(TravelCoordinator, parentModel.model);
361
+
362
+ const delegationLayer = durableDestinationResearchHandlersLayer(childBinding).pipe(
363
+ Layer.provide(
364
+ Layer.mergeAll(
365
+ childToolkitLayer,
366
+ SubagentReservationsMemoryLive,
367
+ DeterministicIdGeneratorLayer,
368
+ ResearchDispatchGate.layerOpen,
369
+ ),
370
+ ),
371
+ );
372
+
373
+ const parentResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
374
+ parentBinding,
375
+ s2CoordinatorDigests,
376
+ ).pipe(Effect.provide(delegationLayer));
377
+ const childResolved: ResolvedBinding = yield* DurableWorkerBinding.make(
378
+ childBinding,
379
+ options?.childRegistrationDigests ?? s2ResearcherDigests,
380
+ ).pipe(Effect.provide(childToolkitLayer));
381
+
382
+ const harness: DurableResearchHarness = {
383
+ bindings: [parentResolved, childResolved],
384
+ parentModelCalls: parentModel.calls,
385
+ parentPrompts: parentModel.prompts,
386
+ childModelCalls: childModel.calls,
387
+ childPrompts: childModel.prompts,
388
+ guideInvocations: Ref.get(guideInvocations),
389
+ };
390
+ return harness;
391
+ });