@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,923 @@
1
+ import { SubagentReservationsMemoryLive } from "@effect-agent/capabilities";
2
+ import { Agent } from "@effect-agent/core";
3
+ import {
4
+ DefinitionDigests,
5
+ DeploymentId,
6
+ Digest,
7
+ DurableWorkerBinding,
8
+ ProducerId,
9
+ type CanonicalRecordEnvelope,
10
+ type Receipt,
11
+ type ResolvedBinding,
12
+ type ToolReconciler,
13
+ } from "@effect-agent/session";
14
+ import { Duration, Effect, Layer, Schema, Stream } from "effect";
15
+ import { LanguageModel, Model, type Response } from "effect/unstable/ai";
16
+
17
+ import { TravelPlan, TripRequest } from "./definition.ts";
18
+ import {
19
+ DeterministicIdGeneratorLayer,
20
+ SupplierBookingDesk,
21
+ supplierBookingRefFor,
22
+ } from "./deterministic-layers.ts";
23
+ import {
24
+ TravelPlannerDurableEvidenceError,
25
+ TravelPlannerPhase4,
26
+ normalizeDurableTravelPlannerEvidence,
27
+ phase4TravelPlannerDefinitionDigests,
28
+ phase4TravelPlannerWorkerLayer,
29
+ } from "./phase4.ts";
30
+ import {
31
+ TravelPlannerPhase5,
32
+ TravelSupplierReconcilerLayer,
33
+ bookFlightIdempotencyKey,
34
+ phase5TravelPlannerDefinitionDigests,
35
+ phase5TravelPlannerWorkerLayer,
36
+ } from "./phase5.ts";
37
+ import { expectedTravelPlan } from "./scenarios.ts";
38
+ import {
39
+ durableDestinationResearchHandlersLayer,
40
+ durableResearchCallId,
41
+ durableResearchShortlist,
42
+ s2CoordinatorDigests,
43
+ s2ResearcherDigests,
44
+ } from "./subagents-durable.ts";
45
+ import {
46
+ DestinationGuide,
47
+ DestinationResearcher,
48
+ DestinationResearcherToolkitLayer,
49
+ DestinationShortlist,
50
+ ResearchDispatchGate,
51
+ ResearchMission,
52
+ TravelCoordinator,
53
+ destinationLookup,
54
+ encodedDestinationReport,
55
+ } from "./subagents.ts";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Phase 6 (P6): the SAME cumulative Travel Planner on the Cloudflare Durable
59
+ // Object runtime, deployment class DC. This module is deliberately
60
+ // platform-neutral (it imports no Cloudflare types): the worker Bindings it
61
+ // builds are plain `ResolvedBinding` values a Conversation Object registers,
62
+ // and the cross-platform evidence normal form is shared by the DN and DC
63
+ // halves of the equivalence suite (plan §1.8, D-P6-6).
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * The Phase 6 profile: the P4/P5/S2 Travel Planner claims re-earned on the Cloudflare Durable
68
+ * Object runtime (deployment class `DC`), where eviction and alarm redelivery replace process
69
+ * kill and restart as the exercised recovery path. `cloudflareEquivalence` is the claim the S2
70
+ * fixture explicitly deferred to P6 (`TravelPlannerSubagentDurabilityProfile` pins it `false`
71
+ * for `DN`): it flips to `true` here ONLY because the phase-6 suites assert byte-equal
72
+ * cross-platform normalized canonical evidence against one committed golden. Exactly-once
73
+ * EXTERNAL effects remain — deliberately — unclaimed on every platform (DUR-003).
74
+ */
75
+ export class TravelPlannerCloudflareProfile extends Schema.Class<TravelPlannerCloudflareProfile>(
76
+ "@effect-agent/testing/travel-planner/TravelPlannerCloudflareProfile",
77
+ )({
78
+ deploymentClass: Schema.Literal("DC"),
79
+ durableAcceptedWork: Schema.Literal(true),
80
+ canonicalSchemaVersion: Schema.Literal(1),
81
+ /** P5 semantics under DC recovery: prepared/settled records, Unknown Outcomes, approvals. */
82
+ supplierBookingUncertaintyProtocol: Schema.Literal(true),
83
+ /** S2 semantics under DC recovery: cross-Object establishment/join, completed child never re-runs. */
84
+ durableAttachedSubagents: Schema.Literal(true),
85
+ /** DN and DC produce byte-equal cross-platform normalized canonical evidence (one golden). */
86
+ cloudflareEquivalence: Schema.Literal(true),
87
+ /** Never claimed at any phase on any platform (DUR-003). */
88
+ exactlyOnceExternalEffects: Schema.Literal(false),
89
+ }) {}
90
+
91
+ export const phase6TravelPlannerProfile = TravelPlannerCloudflareProfile.make({
92
+ deploymentClass: "DC",
93
+ durableAcceptedWork: true,
94
+ canonicalSchemaVersion: 1,
95
+ supplierBookingUncertaintyProtocol: true,
96
+ durableAttachedSubagents: true,
97
+ cloudflareEquivalence: true,
98
+ exactlyOnceExternalEffects: false,
99
+ });
100
+
101
+ export const phase6TravelPlannerDeploymentId = Schema.decodeSync(DeploymentId)(
102
+ "travel-planner-p6-deployment",
103
+ );
104
+ /** Producer prefix of the DC host; each Object mints `{prefix}:{conversationId}`. */
105
+ export const phase6TravelPlannerProducerPrefix = "travel-planner-p6-producer";
106
+ /** The full producer identity one DC Conversation Object mints for itself. */
107
+ export const phase6TravelPlannerProducerId = (conversationId: string): ProducerId =>
108
+ Schema.decodeSync(ProducerId)(`${phase6TravelPlannerProducerPrefix}:${conversationId}`);
109
+
110
+ const digestOf = (character: string) => Schema.decodeSync(Digest)(character.repeat(64));
111
+
112
+ /**
113
+ * Registration digests of the GATED planner Binding: the same `TravelPlannerPhase4` definition
114
+ * bound to a model whose first response waits on a test gate, addressable separately so the
115
+ * admission-limits rows can hold a lane busy deterministically without touching the ordinary
116
+ * planner registration.
117
+ */
118
+ export const phase6GatedPlannerDefinitionDigests = DefinitionDigests.make({
119
+ agent: digestOf("9"),
120
+ model: digestOf("8"),
121
+ tools: digestOf("7"),
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Cross-platform evidence normal form (plan §1.8, D-P6-6)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** The run-specific identities the cross-platform normal form scrubs. */
129
+ export interface CrossPlatformEvidenceIdentity {
130
+ /** The Conversation lane the evidence came from. */
131
+ readonly conversationId: string;
132
+ /** The host's deployment identity (`DeploymentId` on every record envelope). */
133
+ readonly deploymentId: string;
134
+ /** The full producer identity of the run (DN: configured; DC: `{prefix}:{conversationId}`). */
135
+ readonly producerId: string;
136
+ }
137
+
138
+ const decodeComparableJson = Schema.decodeUnknownEffect(Schema.Json);
139
+
140
+ /** The base normal form's element shape, re-decoded so sequences can be renumbered. */
141
+ const ComparableEnvelope = Schema.Struct({
142
+ batchId: Schema.String,
143
+ sequence: Schema.Number,
144
+ record: Schema.Json,
145
+ });
146
+ const decodeComparableEnvelopes = Schema.decodeUnknownEffect(Schema.Array(ComparableEnvelope));
147
+
148
+ /**
149
+ * The CROSS-PLATFORM extension of `normalizeDurableTravelPlannerEvidence` (D-P6-6): after the
150
+ * base normalization replaces the two ledger-minted identities (which also normalizes the
151
+ * DC-format routable `{uuidv7}:{conversationId}` Submission identities and everything derived
152
+ * from them), this form additionally scrubs everything that legitimately differs between a DN
153
+ * process and a DC Durable Object over the same scenario:
154
+ *
155
+ * - `RepairAnnotated` audit records are dropped BEFORE normalization and the canonical
156
+ * sequence is renumbered to the surviving order: repairs are DUR-013 evidence of recovery
157
+ * itself, legally present in a recovered run and legally absent from an uninterrupted
158
+ * control (on DC even a CLEAN run carries one, because every pass reconciles before it
159
+ * claims, so the ready lane's input is applied through the recovery path). Canonical ORDER
160
+ * is the durability §5 claim; sequence contiguity is a platform artifact of who appended;
161
+ * - the Conversation identity (DC lanes mint unique names per test run);
162
+ * - the deployment and producer identities (host configuration, not canonical semantics);
163
+ * - `createdAt` commit timestamps (wall clock);
164
+ * - 64-hex digests (they hash RAW content that legally embeds run-specific identity, so they
165
+ * can never be byte-equal across runs; chain integrity is asserted separately by the
166
+ * adapters and the convergence helpers).
167
+ *
168
+ * Two runs whose cross-platform normalized evidence is byte-equal took canonically equivalent
169
+ * histories — the exact sense in which durability §5 permits storage differences while
170
+ * requiring the same observable ordering. Both the DN and DC suites assert equality against
171
+ * the one committed `phase6TravelPlannerGoldenEvidence`, so DN ≡ DC transitively.
172
+ */
173
+ export const normalizeCrossPlatformTravelPlannerEvidence = Effect.fn(
174
+ "TravelPlannerPhase6.normalizeCrossPlatformTravelPlannerEvidence",
175
+ )(function* (
176
+ records: ReadonlyArray<CanonicalRecordEnvelope>,
177
+ receipt: Receipt,
178
+ identity: CrossPlatformEvidenceIdentity,
179
+ ): Effect.fn.Return<Schema.Json, TravelPlannerDurableEvidenceError> {
180
+ const canonical = records.filter(
181
+ (envelope) => envelope.record.payload._tag !== "RepairAnnotated",
182
+ );
183
+ const base = yield* normalizeDurableTravelPlannerEvidence(canonical, receipt);
184
+ const scrubbed: unknown = JSON.parse(
185
+ JSON.stringify(base)
186
+ .replaceAll(identity.producerId, "{producerId}")
187
+ .replaceAll(identity.deploymentId, "{deploymentId}")
188
+ .replaceAll(identity.conversationId, "{conversationId}")
189
+ .replaceAll(/\d{4}-\d{2}-\d{2}T[0-9:.]+Z/g, "{timestamp}")
190
+ .replaceAll(/"[0-9a-f]{64}"/g, '"{digest}"'),
191
+ );
192
+ const comparable = yield* decodeComparableEnvelopes(scrubbed).pipe(
193
+ Effect.mapError((error) =>
194
+ TravelPlannerDurableEvidenceError.make({
195
+ message: `Cross-platform normalized evidence lost the comparable shape: ${error.message}`,
196
+ }),
197
+ ),
198
+ );
199
+ const renumbered = comparable.map((entry, index) => ({
200
+ batchId: entry.batchId,
201
+ sequence: index + 1,
202
+ record: entry.record,
203
+ }));
204
+ return yield* decodeComparableJson(renumbered).pipe(
205
+ Effect.mapError((error) =>
206
+ TravelPlannerDurableEvidenceError.make({
207
+ message: `Cross-platform normalized evidence is not comparable JSON: ${error.message}`,
208
+ }),
209
+ ),
210
+ );
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Prompt-aware scripted models. A DC Attempt may resume on a FRESH Object
215
+ // incarnation whose Layers (and any in-memory turn counter) were rebuilt, so
216
+ // every phase-6 model derives its response purely from the committed history
217
+ // in its prompt — the way a real model would — instead of from call order.
218
+ // ---------------------------------------------------------------------------
219
+
220
+ const scriptedUsage = { inputTokens: { total: 128 }, outputTokens: { total: 96 } };
221
+
222
+ const promptAwareModel = (
223
+ name: string,
224
+ decide: (promptJson: string) => Stream.Stream<Response.StreamPartEncoded>,
225
+ ) =>
226
+ Model.make(
227
+ "scripted",
228
+ name,
229
+ Layer.effect(
230
+ LanguageModel.LanguageModel,
231
+ LanguageModel.make({
232
+ generateText: () => Effect.succeed([]),
233
+ streamText: (options) =>
234
+ Stream.unwrap(Effect.sync(() => decide(JSON.stringify(options.prompt)))),
235
+ }),
236
+ ),
237
+ );
238
+
239
+ /** The P1/P4 happy-path Tool Call identities (scenarios.ts, byte-stable since P1). */
240
+ export const phase6FlightCallId = "flight-call-1";
241
+ export const phase6LodgingCallId = "lodging-call-1";
242
+ export const phase6ActivityCallId = "activity-call-1";
243
+
244
+ /** Turn 1 of the planner: the SAME three search declarations as `phase1HappyPathTurns`. */
245
+ const plannerSearchTurnParts: ReadonlyArray<Response.StreamPartEncoded> = [
246
+ {
247
+ type: "tool-call",
248
+ id: phase6FlightCallId,
249
+ name: "search_flights",
250
+ params: { origin: "SFO", destination: "LHR", departOn: "2026-09-14", travelers: 2 },
251
+ },
252
+ {
253
+ type: "tool-call",
254
+ id: phase6LodgingCallId,
255
+ name: "search_lodging",
256
+ params: { destination: "LHR", departOn: "2026-09-14", nights: 4, travelers: 2 },
257
+ },
258
+ {
259
+ type: "tool-call",
260
+ id: phase6ActivityCallId,
261
+ name: "search_activities",
262
+ params: { destination: "LHR", departOn: "2026-09-14", nights: 4, travelers: 2 },
263
+ },
264
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
265
+ ];
266
+
267
+ /** Turn 2 of the planner: the SAME itinerary text as `phase1HappyPathTurns`. */
268
+ const plannerPlanTurnParts: ReadonlyArray<Response.StreamPartEncoded> = [
269
+ { type: "text-start", id: "itinerary-json" },
270
+ {
271
+ type: "text-delta",
272
+ id: "itinerary-json",
273
+ delta: JSON.stringify(Schema.encodeSync(TravelPlan)(expectedTravelPlan)),
274
+ },
275
+ { type: "text-end", id: "itinerary-json" },
276
+ { type: "finish", reason: "stop", usage: scriptedUsage },
277
+ ];
278
+
279
+ const plannerDecide = (promptJson: string): Stream.Stream<Response.StreamPartEncoded> =>
280
+ promptJson.includes(phase6FlightCallId)
281
+ ? Stream.fromIterable(plannerPlanTurnParts)
282
+ : Stream.fromIterable(plannerSearchTurnParts);
283
+
284
+ /**
285
+ * The P4 planner script (`phase1HappyPathTurns`) as a prompt-aware model: once the search
286
+ * batch is committed history, every later request gets the plan — identical parts, so the DC
287
+ * canonical evidence is byte-equivalent to the DN ScriptedModel run after normalization.
288
+ */
289
+ export const phase6PlannerModel = promptAwareModel("travel-planner-phase-4", plannerDecide);
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Deterministic test gate for the admission-limits rows. Module state is
293
+ // intentionally NOT durable: it plays the external world's role (a slow
294
+ // upstream model), never Conversation state.
295
+ // ---------------------------------------------------------------------------
296
+
297
+ const releasedPlannerGates = new Set<string>();
298
+
299
+ /** Release the gated planner model for one `[gate:...]` marker. */
300
+ export const releasePhase6PlannerGate = (marker: string): void => {
301
+ releasedPlannerGates.add(marker);
302
+ };
303
+
304
+ /** Re-close one gate marker (fresh suites reuse markers safely). */
305
+ export const resetPhase6PlannerGate = (marker: string): void => {
306
+ releasedPlannerGates.delete(marker);
307
+ };
308
+
309
+ const awaitPlannerGate = (marker: string): Effect.Effect<void> =>
310
+ Effect.gen(function* () {
311
+ while (!releasedPlannerGates.has(marker)) {
312
+ yield* Effect.sleep(Duration.millis(10));
313
+ }
314
+ });
315
+
316
+ const gateMarkerFromPrompt = (promptJson: string): string =>
317
+ /\[gate:([^\]]+)\]/.exec(promptJson)?.[1] ?? "unknown-gate";
318
+
319
+ /** A trip whose request text carries the gate marker the gated model waits on. */
320
+ export const phase6GatedTrip = (marker: string): TripRequest =>
321
+ Schema.decodeUnknownSync(TripRequest)({
322
+ request: `Plan a review-only London trip, but wait for the concierge. [gate:${marker}]`,
323
+ origin: "SFO",
324
+ destination: "LHR",
325
+ departOn: "2026-09-14",
326
+ nights: 4,
327
+ travelers: 2,
328
+ budgetCents: 350_000,
329
+ currency: "USD",
330
+ });
331
+
332
+ /**
333
+ * The SAME planner behavior with a hanging first response: the model waits on the released
334
+ * gate before answering, keeping its lane durably busy so queue-depth admission limits can be
335
+ * exercised deterministically.
336
+ */
337
+ export const phase6GatedPlannerModel = Model.make(
338
+ "scripted",
339
+ "travel-planner-phase-4-gated",
340
+ Layer.effect(
341
+ LanguageModel.LanguageModel,
342
+ LanguageModel.make({
343
+ generateText: () => Effect.succeed([]),
344
+ streamText: (options) =>
345
+ Stream.unwrap(
346
+ Effect.sync(() => {
347
+ const promptJson = JSON.stringify(options.prompt);
348
+ return promptJson.includes(phase6FlightCallId)
349
+ ? plannerDecide(promptJson)
350
+ : Stream.fromEffectDrain(awaitPlannerGate(gateMarkerFromPrompt(promptJson))).pipe(
351
+ Stream.concat(plannerDecide(promptJson)),
352
+ );
353
+ }),
354
+ ),
355
+ }),
356
+ ),
357
+ );
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // P5 booking slice: the SAME phase-5 booking agent and supplier desk. The desk
361
+ // is a module-level singleton because it IS the external supplier: like a real
362
+ // supplier's ledger, its bookings and call counters survive `ctx.abort()` and
363
+ // incarnation loss, which is exactly what the never-fabricate and
364
+ // executed-once assertions measure.
365
+ // ---------------------------------------------------------------------------
366
+
367
+ const sharedSupplierDesk = Effect.runSync(
368
+ Effect.flatMap(SupplierBookingDesk, Effect.succeed).pipe(
369
+ Effect.provide(SupplierBookingDesk.layer),
370
+ ),
371
+ );
372
+
373
+ /** The shared external supplier desk instance (module-level external truth). */
374
+ export const phase6SupplierDesk = sharedSupplierDesk;
375
+
376
+ /** Layer handing the shared desk to Bindings, reconcilers, and assertions. */
377
+ export const phase6SupplierDeskLayer: Layer.Layer<SupplierBookingDesk> = Layer.succeed(
378
+ SupplierBookingDesk,
379
+ sharedSupplierDesk,
380
+ );
381
+
382
+ /**
383
+ * The REAL P5 supplier reconciliation policy over the shared desk, closed to no requirements
384
+ * so a Conversation Object can install it directly: `book_flight` recovers only from supplier
385
+ * truth (absence stays fail-closed `Uncertain` → durable Unknown Outcome), keyed Steps are
386
+ * provably re-enterable.
387
+ */
388
+ export const phase6SupplierReconcilerLayer: Layer.Layer<ToolReconciler> =
389
+ TravelSupplierReconcilerLayer.pipe(Layer.provide(phase6SupplierDeskLayer));
390
+
391
+ const bookingMarkerFromPrompt = (promptJson: string): string =>
392
+ /\[case:([^\]]+)\]/.exec(promptJson)?.[1] ?? "unknown-case";
393
+
394
+ /** The deterministic booking Tool Call identity for one `[case:...]` marker. */
395
+ export const phase6BookingToolCallId = (marker: string): string => `book-${marker}`;
396
+
397
+ /** The bookingRef the supplier desk mints for one marker's approved booking. */
398
+ export const phase6BookingRef = (marker: string): string =>
399
+ supplierBookingRefFor(bookFlightIdempotencyKey(phase6BookingToolCallId(marker)));
400
+
401
+ /** A trip whose request text carries the per-lane booking case marker. */
402
+ export const phase6BookingTrip = (marker: string): TripRequest =>
403
+ Schema.decodeUnknownSync(TripRequest)({
404
+ request: `Book the approved London flight for the traveler. [case:${marker}]`,
405
+ origin: "SFO",
406
+ destination: "LHR",
407
+ departOn: "2026-09-14",
408
+ nights: 4,
409
+ travelers: 2,
410
+ budgetCents: 350_000,
411
+ currency: "USD",
412
+ });
413
+
414
+ const bookingCallParts = (marker: string): ReadonlyArray<Response.StreamPartEncoded> => [
415
+ {
416
+ type: "tool-call",
417
+ id: phase6BookingToolCallId(marker),
418
+ name: "book_flight",
419
+ params: {
420
+ quoteId: "quote-sfo-lhr-001",
421
+ travelerRef: `traveler-${marker}`,
422
+ departOn: "2026-09-14",
423
+ },
424
+ providerExecuted: false,
425
+ },
426
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
427
+ ];
428
+
429
+ const bookingReportParts = (marker: string): ReadonlyArray<Response.StreamPartEncoded> => [
430
+ { type: "text-start", id: "booking-report" },
431
+ {
432
+ type: "text-delta",
433
+ id: "booking-report",
434
+ delta: JSON.stringify({
435
+ summary: "trip booked",
436
+ bookingRefs: [phase6BookingRef(marker)],
437
+ }),
438
+ },
439
+ { type: "text-end", id: "booking-report" },
440
+ { type: "finish", reason: "stop", usage: scriptedUsage },
441
+ ];
442
+
443
+ /**
444
+ * The P5 booking script as a prompt-aware model: request 1 declares the approval-gated
445
+ * `book_flight` call (identity derived from the lane's `[case:...]` marker so supplier
446
+ * idempotency keys never collide across lanes); once that call is committed history, the model
447
+ * writes the booking report.
448
+ */
449
+ export const phase6BookingModel = promptAwareModel("travel-planner-phase-5", (promptJson) => {
450
+ const marker = bookingMarkerFromPrompt(promptJson);
451
+ return promptJson.includes(phase6BookingToolCallId(marker))
452
+ ? Stream.fromIterable(bookingReportParts(marker))
453
+ : Stream.fromIterable(bookingCallParts(marker));
454
+ });
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // S2 delegation slice: the SAME coordinator → destination-researcher pair.
458
+ // The guide invocation counter is module state for the same reason as the
459
+ // desk: it is the child's external side-effect record, and "the completed
460
+ // child never re-executes" is asserted against it across evictions.
461
+ // ---------------------------------------------------------------------------
462
+
463
+ let guideInvocations = 0;
464
+
465
+ /** Deterministic guide-lookup handler executions across every incarnation. */
466
+ export const phase6GuideInvocationCount = (): number => guideInvocations;
467
+
468
+ const countingGuideLayer = Layer.succeed(
469
+ DestinationGuide,
470
+ DestinationGuide.of({
471
+ lookup: (query) =>
472
+ Effect.suspend(() => {
473
+ guideInvocations += 1;
474
+ return destinationLookup(query);
475
+ }),
476
+ }),
477
+ );
478
+
479
+ /** The one-candidate research mission of the DC delegation slice. */
480
+ export const phase6ResearchMission = Schema.decodeUnknownSync(ResearchMission)({
481
+ request: "Shortlist one September culture city for the DC delegation slice.",
482
+ candidates: ["LHR"],
483
+ });
484
+
485
+ export const phase6ResearchDestination = "LHR";
486
+
487
+ /** The child's scripted guide-lookup Tool Call identity. */
488
+ export const phase6ChildLookupCallId = `lookup-${phase6ResearchDestination}`;
489
+
490
+ const coordinatorDelegationParts: ReadonlyArray<Response.StreamPartEncoded> = [
491
+ {
492
+ type: "tool-call",
493
+ id: durableResearchCallId,
494
+ name: "delegate_destination_research",
495
+ params: { destination: phase6ResearchDestination, focus: "museums" },
496
+ providerExecuted: false,
497
+ },
498
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
499
+ ];
500
+
501
+ const coordinatorShortlistParts: ReadonlyArray<Response.StreamPartEncoded> = [
502
+ { type: "text-start", id: "shortlist" },
503
+ {
504
+ type: "text-delta",
505
+ id: "shortlist",
506
+ delta: JSON.stringify(
507
+ Schema.encodeSync(DestinationShortlist)(durableResearchShortlist(phase6ResearchDestination)),
508
+ ),
509
+ },
510
+ { type: "text-end", id: "shortlist" },
511
+ { type: "finish", reason: "stop", usage: scriptedUsage },
512
+ ];
513
+
514
+ const researcherLookupParts: ReadonlyArray<Response.StreamPartEncoded> = [
515
+ {
516
+ type: "tool-call",
517
+ id: phase6ChildLookupCallId,
518
+ name: "lookup_destination",
519
+ params: { destination: phase6ResearchDestination },
520
+ providerExecuted: false,
521
+ },
522
+ { type: "finish", reason: "tool-calls", usage: scriptedUsage },
523
+ ];
524
+
525
+ const researcherReportParts: ReadonlyArray<Response.StreamPartEncoded> = [
526
+ { type: "text-start", id: "destination-report" },
527
+ {
528
+ type: "text-delta",
529
+ id: "destination-report",
530
+ delta: encodedDestinationReport(phase6ResearchDestination),
531
+ },
532
+ { type: "text-end", id: "destination-report" },
533
+ { type: "finish", reason: "stop", usage: scriptedUsage },
534
+ ];
535
+
536
+ /** Prompt-aware S2 coordinator: delegation call first, shortlist once it is history. */
537
+ export const phase6CoordinatorModel = promptAwareModel("travel-coordinator-p6", (promptJson) =>
538
+ promptJson.includes(durableResearchCallId)
539
+ ? Stream.fromIterable(coordinatorShortlistParts)
540
+ : Stream.fromIterable(coordinatorDelegationParts),
541
+ );
542
+
543
+ let researcherGateReleased = false;
544
+
545
+ /** Allow the researcher's FIRST model response to proceed (sticky across incarnations). */
546
+ export const releasePhase6ResearcherGate = (): void => {
547
+ researcherGateReleased = true;
548
+ };
549
+
550
+ /** Re-close the researcher gate (each delegation scenario starts gated). */
551
+ export const resetPhase6ResearcherGate = (): void => {
552
+ researcherGateReleased = false;
553
+ };
554
+
555
+ const awaitResearcherGate: Effect.Effect<void> = Effect.gen(function* () {
556
+ while (!researcherGateReleased) {
557
+ yield* Effect.sleep(Duration.millis(10));
558
+ }
559
+ });
560
+
561
+ /**
562
+ * Prompt-aware S2 researcher: guide lookup first, report once it is history. The FIRST
563
+ * response waits on the researcher gate — a stand-in for real model latency. The child's own
564
+ * Object may legally start its Attempt the moment its routed admission commits, while the
565
+ * parent is still appending the lineage record into the child's log; a child whose first
566
+ * batch commits during that window races the parent's append on one tail. Real models answer
567
+ * in seconds, so establishment always wins that race in production; the gate reproduces that
568
+ * timing deterministically instead of relying on scheduler luck.
569
+ */
570
+ export const phase6ResearcherModel = promptAwareModel("destination-researcher-p6", (promptJson) =>
571
+ promptJson.includes(phase6ChildLookupCallId)
572
+ ? Stream.fromIterable(researcherReportParts)
573
+ : Stream.fromEffectDrain(awaitResearcherGate).pipe(
574
+ Stream.concat(Stream.fromIterable(researcherLookupParts)),
575
+ ),
576
+ );
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // Worker Binding registrations for the DC Conversation Object
580
+ // ---------------------------------------------------------------------------
581
+
582
+ /**
583
+ * Every phase-6 Travel Planner worker Binding, captured with its requirement Contexts
584
+ * (spec/subagents.md §11): the P4 planner and its gated twin, the P5 booking agent over the
585
+ * shared supplier desk, and the S2 coordinator/researcher pair wired through the durable
586
+ * delegation Layer. A Conversation Object registers these via its `bindings` option; the
587
+ * capture runs once per incarnation, and everything stateful the assertions rely on (desk,
588
+ * guide counter, gates) lives at module level so it survives incarnation loss.
589
+ */
590
+ export const makePhase6TravelPlannerBindings: Effect.Effect<ReadonlyArray<ResolvedBinding>> =
591
+ Effect.gen(function* () {
592
+ const planner: ResolvedBinding = yield* DurableWorkerBinding.make(
593
+ Agent.withModel(TravelPlannerPhase4, phase6PlannerModel),
594
+ phase4TravelPlannerDefinitionDigests,
595
+ ).pipe(Effect.provide(phase4TravelPlannerWorkerLayer));
596
+
597
+ const gatedPlanner: ResolvedBinding = yield* DurableWorkerBinding.make(
598
+ Agent.withModel(TravelPlannerPhase4, phase6GatedPlannerModel),
599
+ phase6GatedPlannerDefinitionDigests,
600
+ ).pipe(Effect.provide(phase4TravelPlannerWorkerLayer));
601
+
602
+ const booking: ResolvedBinding = yield* DurableWorkerBinding.make(
603
+ Agent.withModel(TravelPlannerPhase5, phase6BookingModel),
604
+ phase5TravelPlannerDefinitionDigests,
605
+ ).pipe(
606
+ Effect.provide(
607
+ phase5TravelPlannerWorkerLayer.pipe(Layer.provideMerge(phase6SupplierDeskLayer)),
608
+ ),
609
+ );
610
+
611
+ const researcherBinding = Agent.withModel(DestinationResearcher, phase6ResearcherModel);
612
+ const childToolkitLayer = DestinationResearcherToolkitLayer.pipe(
613
+ Layer.provideMerge(countingGuideLayer),
614
+ );
615
+
616
+ const coordinator: ResolvedBinding = yield* DurableWorkerBinding.make(
617
+ Agent.withModel(TravelCoordinator, phase6CoordinatorModel),
618
+ s2CoordinatorDigests,
619
+ ).pipe(
620
+ Effect.provide(
621
+ durableDestinationResearchHandlersLayer(researcherBinding).pipe(
622
+ Layer.provide(
623
+ Layer.mergeAll(
624
+ childToolkitLayer,
625
+ SubagentReservationsMemoryLive,
626
+ DeterministicIdGeneratorLayer,
627
+ ResearchDispatchGate.layerOpen,
628
+ ),
629
+ ),
630
+ ),
631
+ ),
632
+ );
633
+
634
+ const researcher: ResolvedBinding = yield* DurableWorkerBinding.make(
635
+ researcherBinding,
636
+ s2ResearcherDigests,
637
+ ).pipe(Effect.provide(childToolkitLayer));
638
+
639
+ return [planner, gatedPlanner, booking, coordinator, researcher];
640
+ });
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // The committed golden normalized-evidence fixture (D-P6-6)
644
+ // ---------------------------------------------------------------------------
645
+
646
+ /**
647
+ * The committed cross-platform normalized canonical evidence of ONE uninterrupted Travel
648
+ * Planner planning Submission (the P1/P4 happy path: canonical input, the search Turn, three
649
+ * Tool settlements, the plan Turn, one Settlement). `travel-planner-phase6.test.ts` asserts
650
+ * the DN run equals this value and `travel-planner-dc.test.ts` asserts the DC run equals this
651
+ * value, so the two platforms' canonical outcomes are byte-equivalent transitively — the P6
652
+ * exit gate "Travel Planner produces equivalent canonical outcomes under DN and DC".
653
+ *
654
+ * Regenerate ONLY when the Travel Planner scenario itself changes, by printing either suite's
655
+ * normalized value; both suites must then agree on the new golden.
656
+ */
657
+ export const phase6TravelPlannerGoldenEvidence: Schema.Json = [
658
+ {
659
+ batchId: "conversation-created:{conversationId}",
660
+ sequence: 1,
661
+ record: {
662
+ recordId: "conversation-created:{conversationId}",
663
+ family: "conversation",
664
+ schemaVersion: 1,
665
+ createdAt: "{timestamp}",
666
+ deploymentId: "{deploymentId}",
667
+ payload: {
668
+ _tag: "ConversationCreated",
669
+ agentId: "travel-planner-phase-4",
670
+ definitions: {
671
+ agent: "{digest}",
672
+ model: "{digest}",
673
+ tools: "{digest}",
674
+ },
675
+ },
676
+ },
677
+ },
678
+ {
679
+ batchId: "submission-input:{submissionId}",
680
+ sequence: 2,
681
+ record: {
682
+ recordId: "input:{submissionId}",
683
+ family: "conversation",
684
+ schemaVersion: 1,
685
+ createdAt: "{timestamp}",
686
+ deploymentId: "{deploymentId}",
687
+ payload: {
688
+ _tag: "UserInputRecorded",
689
+ submissionId: "{submissionId}",
690
+ kind: "user",
691
+ runId: "run:{submissionId}",
692
+ input: {
693
+ request:
694
+ "Plan a review-only London trip using the deterministic flight, lodging, and activity searches.",
695
+ origin: "SFO",
696
+ destination: "LHR",
697
+ departOn: "2026-09-14",
698
+ nights: 4,
699
+ travelers: 2,
700
+ budgetCents: 350000,
701
+ currency: "USD",
702
+ },
703
+ },
704
+ },
705
+ },
706
+ {
707
+ batchId: "turn-response:run:{submissionId}:1",
708
+ sequence: 3,
709
+ record: {
710
+ recordId: "model-response:run:{submissionId}:1",
711
+ family: "conversation",
712
+ schemaVersion: 1,
713
+ createdAt: "{timestamp}",
714
+ deploymentId: "{deploymentId}",
715
+ payload: {
716
+ _tag: "ModelResponseRecorded",
717
+ runId: "run:{submissionId}",
718
+ turnId: "turn:run:{submissionId}:1",
719
+ turn: 1,
720
+ messages: {
721
+ content: [
722
+ {
723
+ options: {},
724
+ role: "system",
725
+ content:
726
+ 'You are the Effect Agent Travel Planner P1 interpreter fixture.\nThe user asked: Plan a review-only London trip using the deterministic flight, lodging, and activity searches.\nCall search_flights, search_lodging, and search_activities exactly once in one Tool batch.\nThen return only a JSON object of exactly this shape, no prose:\n{"itineraries": [{"title": "<short itinerary name>", "route": "<origin-destination>", "dates": "<date range>", "flight": "<flight description from the Tool result>", "lodging": "<lodging description from the Tool result>", "activities": ["<activity>", "..."], "estimatedTotalCents": <positive integer total in cents>, "currency": "USD", "quoteId": "<quoteId from the flight Tool result>", "assumptions": ["<assumption>", "..."], "unresolvedConstraints": [], "nextAction": "review"}]}\nUse the Tool results verbatim; activity results may legitimately be an empty array.\nThis is read-only planning. Require review before any mutation.',
727
+ },
728
+ {
729
+ options: {},
730
+ role: "user",
731
+ content:
732
+ '{"request":"Plan a review-only London trip using the deterministic flight, lodging, and activity searches.","origin":"SFO","destination":"LHR","departOn":"2026-09-14","nights":4,"travelers":2,"budgetCents":350000,"currency":"USD"}',
733
+ },
734
+ {
735
+ options: {},
736
+ role: "assistant",
737
+ content: [
738
+ {
739
+ options: {},
740
+ type: "tool-call",
741
+ id: "flight-call-1",
742
+ name: "search_flights",
743
+ params: {
744
+ origin: "SFO",
745
+ destination: "LHR",
746
+ departOn: "2026-09-14",
747
+ travelers: 2,
748
+ },
749
+ providerExecuted: false,
750
+ },
751
+ {
752
+ options: {},
753
+ type: "tool-call",
754
+ id: "lodging-call-1",
755
+ name: "search_lodging",
756
+ params: {
757
+ destination: "LHR",
758
+ departOn: "2026-09-14",
759
+ nights: 4,
760
+ travelers: 2,
761
+ },
762
+ providerExecuted: false,
763
+ },
764
+ {
765
+ options: {},
766
+ type: "tool-call",
767
+ id: "activity-call-1",
768
+ name: "search_activities",
769
+ params: {
770
+ destination: "LHR",
771
+ departOn: "2026-09-14",
772
+ nights: 4,
773
+ travelers: 2,
774
+ },
775
+ providerExecuted: false,
776
+ },
777
+ ],
778
+ },
779
+ ],
780
+ },
781
+ messagesDigest: "{digest}",
782
+ },
783
+ },
784
+ },
785
+ {
786
+ batchId: "turn-results:run:{submissionId}:1",
787
+ sequence: 4,
788
+ record: {
789
+ recordId: "tool-settled:run:{submissionId}:1:flight-call-1",
790
+ family: "conversation",
791
+ schemaVersion: 1,
792
+ createdAt: "{timestamp}",
793
+ deploymentId: "{deploymentId}",
794
+ payload: {
795
+ _tag: "ToolCallSettled",
796
+ runId: "run:{submissionId}",
797
+ toolCallId: "flight-call-1",
798
+ toolName: "search_flights",
799
+ result: {
800
+ quoteId: "quote-sfo-lhr-001",
801
+ flight: "EA 218 · nonstop · SFO 18:40 → LHR 13:05+1",
802
+ estimatedCents: 180000,
803
+ currency: "USD",
804
+ },
805
+ isFailure: false,
806
+ },
807
+ },
808
+ },
809
+ {
810
+ batchId: "turn-results:run:{submissionId}:1",
811
+ sequence: 5,
812
+ record: {
813
+ recordId: "tool-settled:run:{submissionId}:1:lodging-call-1",
814
+ family: "conversation",
815
+ schemaVersion: 1,
816
+ createdAt: "{timestamp}",
817
+ deploymentId: "{deploymentId}",
818
+ payload: {
819
+ _tag: "ToolCallSettled",
820
+ runId: "run:{submissionId}",
821
+ toolCallId: "lodging-call-1",
822
+ toolName: "search_lodging",
823
+ result: {
824
+ lodging: "Bloomsbury House · refundable studio · 4 nights",
825
+ estimatedCents: 104000,
826
+ currency: "USD",
827
+ },
828
+ isFailure: false,
829
+ },
830
+ },
831
+ },
832
+ {
833
+ batchId: "turn-results:run:{submissionId}:1",
834
+ sequence: 6,
835
+ record: {
836
+ recordId: "tool-settled:run:{submissionId}:1:activity-call-1",
837
+ family: "conversation",
838
+ schemaVersion: 1,
839
+ createdAt: "{timestamp}",
840
+ deploymentId: "{deploymentId}",
841
+ payload: {
842
+ _tag: "ToolCallSettled",
843
+ runId: "run:{submissionId}",
844
+ toolCallId: "activity-call-1",
845
+ toolName: "search_activities",
846
+ result: {
847
+ activities: ["British Museum timed entry", "Thames evening walk"],
848
+ },
849
+ isFailure: false,
850
+ },
851
+ },
852
+ },
853
+ {
854
+ batchId: "turn:run:{submissionId}:2",
855
+ sequence: 7,
856
+ record: {
857
+ recordId: "model-response:run:{submissionId}:2",
858
+ family: "conversation",
859
+ schemaVersion: 1,
860
+ createdAt: "{timestamp}",
861
+ deploymentId: "{deploymentId}",
862
+ payload: {
863
+ _tag: "ModelResponseRecorded",
864
+ runId: "run:{submissionId}",
865
+ turnId: "turn:run:{submissionId}:2",
866
+ turn: 2,
867
+ messages: {
868
+ content: [
869
+ {
870
+ options: {},
871
+ role: "assistant",
872
+ content:
873
+ '{"itineraries":[{"title":"Westward light, eastbound overnight","route":"San Francisco → London","dates":"14–19 September 2026","flight":"EA 218 · nonstop · SFO 18:40 → LHR 13:05+1","lodging":"Bloomsbury House · refundable studio · 4 nights","activities":["British Museum timed entry","Thames evening walk"],"estimatedTotalCents":284000,"currency":"USD","quoteId":"quote-sfo-lhr-001","assumptions":["Two travelers sharing one studio","Quote is read-only availability, not a reservation"],"unresolvedConstraints":["Traveler names and accessibility requests are intentionally omitted"],"nextAction":"review"}]}',
874
+ },
875
+ ],
876
+ },
877
+ messagesDigest: "{digest}",
878
+ },
879
+ },
880
+ },
881
+ {
882
+ batchId: "submission-settlement:{submissionId}",
883
+ sequence: 8,
884
+ record: {
885
+ recordId: "settlement:{submissionId}",
886
+ family: "conversation",
887
+ schemaVersion: 1,
888
+ createdAt: "{timestamp}",
889
+ deploymentId: "{deploymentId}",
890
+ payload: {
891
+ _tag: "SubmissionSettled",
892
+ submissionId: "{submissionId}",
893
+ settlementId: "settlement:{submissionId}",
894
+ receiptId: "{receiptId}",
895
+ outcome: "completed",
896
+ runId: "run:{submissionId}",
897
+ result: {
898
+ itineraries: [
899
+ {
900
+ title: "Westward light, eastbound overnight",
901
+ route: "San Francisco → London",
902
+ dates: "14–19 September 2026",
903
+ flight: "EA 218 · nonstop · SFO 18:40 → LHR 13:05+1",
904
+ lodging: "Bloomsbury House · refundable studio · 4 nights",
905
+ activities: ["British Museum timed entry", "Thames evening walk"],
906
+ estimatedTotalCents: 284000,
907
+ currency: "USD",
908
+ quoteId: "quote-sfo-lhr-001",
909
+ assumptions: [
910
+ "Two travelers sharing one studio",
911
+ "Quote is read-only availability, not a reservation",
912
+ ],
913
+ unresolvedConstraints: [
914
+ "Traveler names and accessibility requests are intentionally omitted",
915
+ ],
916
+ nextAction: "review",
917
+ },
918
+ ],
919
+ },
920
+ },
921
+ },
922
+ },
923
+ ];