@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,431 @@
1
+ import { ConversationId, IdGenerator, RunId, TurnId } from "@effect-agent/core";
2
+ import { Context, Deferred, Effect, Layer, Option, Ref, Schema } from "effect";
3
+
4
+ import {
5
+ ActivityCatalog,
6
+ ActivitySearchResult,
7
+ ActivityUnavailable,
8
+ FlightCatalog,
9
+ FlightOption,
10
+ FlightUnavailable,
11
+ LodgingCatalog,
12
+ LodgingOption,
13
+ LodgingUnavailable,
14
+ QuoteId,
15
+ TravelGuidance,
16
+ TravelPlannerToolkit,
17
+ TravelPlannerToolkitLayer,
18
+ } from "./definition.ts";
19
+
20
+ export class CatalogLifecycleCounts extends Schema.Class<CatalogLifecycleCounts>(
21
+ "CatalogLifecycleCounts",
22
+ )({
23
+ acquired: Schema.Natural,
24
+ finalized: Schema.Natural,
25
+ }) {}
26
+ export class CatalogLifecycle extends Context.Service<
27
+ CatalogLifecycle,
28
+ {
29
+ readonly markAcquired: Effect.Effect<void>;
30
+ readonly markFinalized: Effect.Effect<void>;
31
+ readonly counts: Effect.Effect<CatalogLifecycleCounts>;
32
+ }
33
+ >()("@effect-agent/testing/travel-planner/CatalogLifecycle") {
34
+ static readonly layerNoDeps = Layer.effect(
35
+ this,
36
+ Effect.gen(function* () {
37
+ const acquired = yield* Ref.make(0);
38
+ const finalized = yield* Ref.make(0);
39
+ return CatalogLifecycle.of({
40
+ markAcquired: Ref.update(acquired, (n) => n + 1),
41
+ markFinalized: Ref.update(finalized, (n) => n + 1),
42
+ counts: Effect.all({ acquired: Ref.get(acquired), finalized: Ref.get(finalized) }).pipe(
43
+ Effect.map((counts) => CatalogLifecycleCounts.make(counts)),
44
+ ),
45
+ });
46
+ }),
47
+ );
48
+ }
49
+
50
+ const flight = FlightOption.make({
51
+ quoteId: Schema.decodeSync(QuoteId)("quote-sfo-lhr-001"),
52
+ flight: "EA 218 · nonstop · SFO 18:40 → LHR 13:05+1",
53
+ estimatedCents: 180_000,
54
+ currency: "USD",
55
+ });
56
+ const lodging = LodgingOption.make({
57
+ lodging: "Bloomsbury House · refundable studio · 4 nights",
58
+ estimatedCents: 104_000,
59
+ currency: "USD",
60
+ });
61
+ const activities = ActivitySearchResult.make({
62
+ activities: ["British Museum timed entry", "Thames evening walk"],
63
+ });
64
+
65
+ /**
66
+ * Deterministic controls for a Tool batch whose completions are released in a
67
+ * caller-selected order. This is intentionally a test fixture: it uses no
68
+ * clock or sleep and lets engine scheduler tests prove parallel starts and
69
+ * declaration-order prompt materialization.
70
+ */
71
+ export interface TravelPlannerCompletionControls {
72
+ readonly flightStarted: Effect.Effect<void>;
73
+ readonly lodgingStarted: Effect.Effect<void>;
74
+ readonly activityStarted: Effect.Effect<void>;
75
+ readonly releaseFlight: Effect.Effect<void>;
76
+ readonly releaseLodging: Effect.Effect<void>;
77
+ readonly releaseActivity: Effect.Effect<void>;
78
+ }
79
+
80
+ export const ReverseCompletionToolkitLayer = Effect.gen(function* () {
81
+ const flightStarted = yield* Deferred.make<void>();
82
+ const lodgingStarted = yield* Deferred.make<void>();
83
+ const activityStarted = yield* Deferred.make<void>();
84
+ const releaseFlight = yield* Deferred.make<void>();
85
+ const releaseLodging = yield* Deferred.make<void>();
86
+ const releaseActivity = yield* Deferred.make<void>();
87
+ const awaitRelease = <A>(
88
+ started: Deferred.Deferred<void>,
89
+ release: Deferred.Deferred<void>,
90
+ value: A,
91
+ ) =>
92
+ Deferred.succeed(started, undefined).pipe(
93
+ Effect.andThen(Deferred.await(release)),
94
+ Effect.as(value),
95
+ );
96
+ return {
97
+ controls: {
98
+ flightStarted: Deferred.await(flightStarted),
99
+ lodgingStarted: Deferred.await(lodgingStarted),
100
+ activityStarted: Deferred.await(activityStarted),
101
+ releaseFlight: Deferred.succeed(releaseFlight, undefined).pipe(Effect.asVoid),
102
+ releaseLodging: Deferred.succeed(releaseLodging, undefined).pipe(Effect.asVoid),
103
+ releaseActivity: Deferred.succeed(releaseActivity, undefined).pipe(Effect.asVoid),
104
+ },
105
+ layer: TravelPlannerToolkit.toLayer({
106
+ search_flights: () => awaitRelease(flightStarted, releaseFlight, flight),
107
+ search_lodging: () => awaitRelease(lodgingStarted, releaseLodging, lodging),
108
+ search_activities: () => awaitRelease(activityStarted, releaseActivity, activities),
109
+ }),
110
+ };
111
+ });
112
+
113
+ export const FlightCatalogLayer = Layer.effect(
114
+ FlightCatalog,
115
+ Effect.gen(function* () {
116
+ const lifecycle = yield* CatalogLifecycle;
117
+ yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
118
+ return FlightCatalog.of({
119
+ search: (query) =>
120
+ query.origin === query.destination
121
+ ? Effect.fail(
122
+ FlightUnavailable.make({
123
+ query: `${query.origin}-${query.destination}`,
124
+ message: "Origin and destination must differ.",
125
+ }),
126
+ )
127
+ : Effect.succeed(flight),
128
+ });
129
+ }),
130
+ );
131
+ export const LodgingCatalogLayer = Layer.effect(
132
+ LodgingCatalog,
133
+ Effect.gen(function* () {
134
+ const lifecycle = yield* CatalogLifecycle;
135
+ yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
136
+ return LodgingCatalog.of({
137
+ search: (query) =>
138
+ query.nights < 1
139
+ ? Effect.fail(
140
+ LodgingUnavailable.make({
141
+ query: query.destination,
142
+ message: "At least one night is required.",
143
+ }),
144
+ )
145
+ : Effect.succeed(lodging),
146
+ });
147
+ }),
148
+ );
149
+ export const ActivityCatalogLayer = Layer.effect(
150
+ ActivityCatalog,
151
+ Effect.gen(function* () {
152
+ const lifecycle = yield* CatalogLifecycle;
153
+ yield* Effect.acquireRelease(lifecycle.markAcquired, () => lifecycle.markFinalized);
154
+ return ActivityCatalog.of({
155
+ search: (query) =>
156
+ query.destination === ""
157
+ ? Effect.fail(
158
+ ActivityUnavailable.make({
159
+ query: query.destination,
160
+ message: "Destination is required.",
161
+ }),
162
+ )
163
+ : Effect.succeed(activities),
164
+ });
165
+ }),
166
+ );
167
+ /** Stable supplier-side booking identity, minted deterministically from the idempotency key. */
168
+ export const BookingRef = Schema.NonEmptyString.pipe(
169
+ Schema.brand("@effect-agent/testing/travel-planner/BookingRef"),
170
+ );
171
+ export type BookingRef = typeof BookingRef.Type;
172
+
173
+ /** The supplier desk operations the P5 booking Tools and Steps invoke. */
174
+ export const SupplierOperation = Schema.Literals([
175
+ "book-flight",
176
+ "cancel-booking",
177
+ "reserve-flight",
178
+ "reserve-lodging",
179
+ "issue-confirmation",
180
+ ]);
181
+ export type SupplierOperation = typeof SupplierOperation.Type;
182
+
183
+ /**
184
+ * One row of external supplier truth. The desk deduplicates by `idempotencyKey` — replaying a
185
+ * call with the same key returns this exact record without creating a second booking — which is
186
+ * precisely the honesty model of DUR-010: the framework never makes an external call
187
+ * exactly-once; the supplier's idempotency key does.
188
+ */
189
+ export class SupplierBookingRecord extends Schema.Class<SupplierBookingRecord>(
190
+ "@effect-agent/testing/travel-planner/SupplierBookingRecord",
191
+ )({
192
+ bookingRef: BookingRef,
193
+ idempotencyKey: Schema.NonEmptyString,
194
+ operation: SupplierOperation,
195
+ detail: Schema.NonEmptyString,
196
+ status: Schema.Literals(["confirmed", "cancelled"]),
197
+ }) {}
198
+
199
+ export class SupplierUnavailable extends Schema.TaggedErrorClass<SupplierUnavailable>()(
200
+ "SupplierUnavailable",
201
+ { message: Schema.String },
202
+ ) {}
203
+
204
+ export interface SupplierBookRequest {
205
+ readonly operation: SupplierOperation;
206
+ readonly idempotencyKey: string;
207
+ readonly detail: string;
208
+ }
209
+
210
+ /** Controls returned by an armed crash window (`holdAfterWrite`). */
211
+ export interface SupplierHoldControls {
212
+ /** Resolves once the armed call has performed its supplier write and is blocked. */
213
+ readonly held: Effect.Effect<void>;
214
+ /** Releases the blocked call (tests that interrupt the Attempt never call this). */
215
+ readonly release: Effect.Effect<void>;
216
+ }
217
+
218
+ /** The desk-internal idempotency key of one cancellation: cancel is idempotent by bookingRef. */
219
+ export const cancelBookingIdempotencyKey = (bookingRef: string): string =>
220
+ `cancel-booking:${bookingRef}`;
221
+
222
+ /** The deterministic bookingRef the desk mints for one idempotency key. */
223
+ export const supplierBookingRefFor = (idempotencyKey: string): BookingRef =>
224
+ Schema.decodeSync(BookingRef)(`ref:${idempotencyKey}`);
225
+
226
+ interface SupplierHoldWindow {
227
+ readonly held: Deferred.Deferred<void>;
228
+ readonly release: Deferred.Deferred<void>;
229
+ }
230
+
231
+ interface SupplierDeskState {
232
+ readonly bookings: ReadonlyMap<string, SupplierBookingRecord>;
233
+ readonly counts: ReadonlyMap<string, number>;
234
+ readonly holds: ReadonlyMap<string, SupplierHoldWindow>;
235
+ }
236
+
237
+ /**
238
+ * The deterministic in-memory supplier: an idempotency-keyed booking store with per-key call
239
+ * counters and injectable crash windows.
240
+ *
241
+ * - `book`/`cancel` always count the call (at-least-once execution stays observable), then
242
+ * dedupe the external effect by idempotency key — the supplier-side contract the P5 Tools and
243
+ * Steps rely on.
244
+ * - `holdAfterWrite` arms a one-shot crash window: the next call with that key performs its
245
+ * supplier write, signals `held`, and never returns. Interrupting the Attempt at that point
246
+ * models "the external effect happened but no outcome was recorded" without any wall clock.
247
+ * - `bookings`/`lookup` expose external truth for the reconciler and for never-fabricate
248
+ * assertions.
249
+ */
250
+ export class SupplierBookingDesk extends Context.Service<
251
+ SupplierBookingDesk,
252
+ {
253
+ readonly book: (
254
+ request: SupplierBookRequest,
255
+ ) => Effect.Effect<SupplierBookingRecord, SupplierUnavailable>;
256
+ readonly cancel: (
257
+ bookingRef: BookingRef,
258
+ ) => Effect.Effect<SupplierBookingRecord, SupplierUnavailable>;
259
+ readonly lookup: (
260
+ idempotencyKey: string,
261
+ ) => Effect.Effect<Option.Option<SupplierBookingRecord>>;
262
+ readonly bookings: Effect.Effect<ReadonlyArray<SupplierBookingRecord>>;
263
+ readonly callCount: (idempotencyKey: string) => Effect.Effect<number>;
264
+ readonly holdAfterWrite: (idempotencyKey: string) => Effect.Effect<SupplierHoldControls>;
265
+ }
266
+ >()("@effect-agent/testing/travel-planner/SupplierBookingDesk") {
267
+ static readonly layer: Layer.Layer<SupplierBookingDesk> = Layer.effect(
268
+ this,
269
+ Effect.gen(function* () {
270
+ const state = yield* Ref.make<SupplierDeskState>({
271
+ bookings: new Map(),
272
+ counts: new Map(),
273
+ holds: new Map(),
274
+ });
275
+
276
+ const enterHold = (hold: Option.Option<SupplierHoldWindow>) =>
277
+ Option.isSome(hold)
278
+ ? Deferred.succeed(hold.value.held, undefined).pipe(
279
+ Effect.andThen(Deferred.await(hold.value.release)),
280
+ )
281
+ : Effect.void;
282
+
283
+ const book = (request: SupplierBookRequest) =>
284
+ Ref.modify(state, (current) => {
285
+ const counts = new Map(current.counts).set(
286
+ request.idempotencyKey,
287
+ (current.counts.get(request.idempotencyKey) ?? 0) + 1,
288
+ );
289
+ const existing = current.bookings.get(request.idempotencyKey);
290
+ const record =
291
+ existing ??
292
+ SupplierBookingRecord.make({
293
+ bookingRef: supplierBookingRefFor(request.idempotencyKey),
294
+ idempotencyKey: request.idempotencyKey,
295
+ operation: request.operation,
296
+ detail: request.detail,
297
+ status: "confirmed",
298
+ });
299
+ const bookings =
300
+ existing === undefined
301
+ ? new Map(current.bookings).set(request.idempotencyKey, record)
302
+ : current.bookings;
303
+ const hold = Option.fromNullishOr(current.holds.get(request.idempotencyKey));
304
+ const holds = Option.isSome(hold)
305
+ ? (() => {
306
+ const next = new Map(current.holds);
307
+ next.delete(request.idempotencyKey);
308
+ return next;
309
+ })()
310
+ : current.holds;
311
+ return [
312
+ { record, hold },
313
+ { bookings, counts, holds },
314
+ ] as const;
315
+ }).pipe(Effect.flatMap(({ hold, record }) => enterHold(hold).pipe(Effect.as(record))));
316
+
317
+ const cancel = (bookingRef: BookingRef) =>
318
+ Ref.modify(state, (current) => {
319
+ const key = cancelBookingIdempotencyKey(bookingRef);
320
+ const counts = new Map(current.counts).set(key, (current.counts.get(key) ?? 0) + 1);
321
+ const existingEntry = [...current.bookings.entries()].find(
322
+ ([, record]) => record.bookingRef === bookingRef,
323
+ );
324
+ if (existingEntry === undefined) {
325
+ return [
326
+ { record: Option.none<SupplierBookingRecord>(), hold: Option.none() },
327
+ { ...current, counts },
328
+ ] as const;
329
+ }
330
+ const [storeKey, existing] = existingEntry;
331
+ const cancelled =
332
+ existing.status === "cancelled"
333
+ ? existing
334
+ : SupplierBookingRecord.make({ ...existing, status: "cancelled" });
335
+ const bookings = new Map(current.bookings).set(storeKey, cancelled);
336
+ const hold = Option.fromNullishOr(current.holds.get(key));
337
+ const holds = Option.isSome(hold)
338
+ ? (() => {
339
+ const next = new Map(current.holds);
340
+ next.delete(key);
341
+ return next;
342
+ })()
343
+ : current.holds;
344
+ return [
345
+ { record: Option.some(cancelled), hold },
346
+ { bookings, counts, holds },
347
+ ] as const;
348
+ }).pipe(
349
+ Effect.flatMap(({ hold, record }) =>
350
+ Option.isNone(record)
351
+ ? Effect.fail(
352
+ SupplierUnavailable.make({
353
+ message: `The supplier desk has no booking under ${bookingRef}.`,
354
+ }),
355
+ )
356
+ : enterHold(hold).pipe(Effect.as(record.value)),
357
+ ),
358
+ );
359
+
360
+ return SupplierBookingDesk.of({
361
+ book,
362
+ cancel,
363
+ lookup: (idempotencyKey) =>
364
+ Ref.get(state).pipe(
365
+ Effect.map((current) => Option.fromNullishOr(current.bookings.get(idempotencyKey))),
366
+ ),
367
+ bookings: Ref.get(state).pipe(Effect.map((current) => [...current.bookings.values()])),
368
+ callCount: (idempotencyKey) =>
369
+ Ref.get(state).pipe(Effect.map((current) => current.counts.get(idempotencyKey) ?? 0)),
370
+ holdAfterWrite: (idempotencyKey) =>
371
+ Effect.gen(function* () {
372
+ const held = yield* Deferred.make<void>();
373
+ const release = yield* Deferred.make<void>();
374
+ yield* Ref.update(state, (current) => ({
375
+ ...current,
376
+ holds: new Map(current.holds).set(idempotencyKey, { held, release }),
377
+ }));
378
+ return {
379
+ held: Deferred.await(held),
380
+ release: Deferred.succeed(release, undefined).pipe(Effect.asVoid),
381
+ };
382
+ }),
383
+ });
384
+ }),
385
+ );
386
+ }
387
+
388
+ export const TravelGuidanceLayer = Layer.succeed(
389
+ TravelGuidance,
390
+ TravelGuidance.of({
391
+ instructions: (input) =>
392
+ Effect.succeed(
393
+ [
394
+ "You are the Effect Agent Travel Planner P1 interpreter fixture.",
395
+ `The user asked: ${input.request}`,
396
+ "Call search_flights, search_lodging, and search_activities exactly once in one Tool batch.",
397
+ "Then return only a JSON object of exactly this shape, no prose:",
398
+ '{"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"}]}',
399
+ "Use the Tool results verbatim; activity results may legitimately be an empty array.",
400
+ "This is read-only planning. Require review before any mutation.",
401
+ ].join("\n"),
402
+ ),
403
+ }),
404
+ );
405
+ export const DeterministicIdGeneratorLayer = Layer.effect(
406
+ IdGenerator,
407
+ Effect.gen(function* () {
408
+ const conversation = yield* Ref.make(0);
409
+ const run = yield* Ref.make(0);
410
+ const turn = yield* Ref.make(0);
411
+ return IdGenerator.of({
412
+ nextConversationId: Ref.updateAndGet(conversation, (n) => n + 1).pipe(
413
+ Effect.map((n) => Schema.decodeSync(ConversationId)(`conversation-${n}`)),
414
+ ),
415
+ nextRunId: Ref.updateAndGet(run, (n) => n + 1).pipe(
416
+ Effect.map((n) => Schema.decodeSync(RunId)(`run-${n}`)),
417
+ ),
418
+ nextTurnId: Ref.updateAndGet(turn, (n) => n + 1).pipe(
419
+ Effect.map((n) => Schema.decodeSync(TurnId)(`turn-${n}`)),
420
+ ),
421
+ });
422
+ }),
423
+ );
424
+ export const TravelPlannerRuntimeLayer = Layer.mergeAll(
425
+ TravelPlannerToolkitLayer,
426
+ FlightCatalogLayer,
427
+ LodgingCatalogLayer,
428
+ ActivityCatalogLayer,
429
+ TravelGuidanceLayer,
430
+ DeterministicIdGeneratorLayer,
431
+ ).pipe(Layer.provide(CatalogLifecycle.layerNoDeps));
@@ -0,0 +1,11 @@
1
+ export * from "./definition.ts";
2
+ export * from "./deterministic-layers.ts";
3
+ export * from "./phase2.ts";
4
+ export * from "./phase3.ts";
5
+ export * from "./phase4.ts";
6
+ export * from "./phase5.ts";
7
+ export * from "./phase6.ts";
8
+ export * from "./phase7.ts";
9
+ export * from "./scenarios.ts";
10
+ export * from "./subagents.ts";
11
+ export * from "./subagents-durable.ts";
@@ -0,0 +1,94 @@
1
+ import { Agent, AgentPolicy } from "@effect-agent/core";
2
+ import { Context, Effect, Schema } from "effect";
3
+ import { Tool, Toolkit } from "effect/unstable/ai";
4
+
5
+ import {
6
+ ActivityCatalog,
7
+ FlightCatalog,
8
+ LodgingCatalog,
9
+ QuoteId,
10
+ SearchActivities,
11
+ SearchFlights,
12
+ SearchLodging,
13
+ TravelGuidance,
14
+ TravelPlan,
15
+ TripRequest,
16
+ } from "./definition.ts";
17
+
18
+ export class ItineraryHoldRequest extends Schema.Class<ItineraryHoldRequest>(
19
+ "@effect-agent/testing/travel-planner/ItineraryHoldRequest",
20
+ )({
21
+ quoteId: QuoteId,
22
+ expiresInMinutes: Schema.Int.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(60)),
23
+ }) {}
24
+
25
+ export class ItineraryHold extends Schema.Class<ItineraryHold>(
26
+ "@effect-agent/testing/travel-planner/ItineraryHold",
27
+ )({
28
+ holdId: Schema.NonEmptyString,
29
+ quoteId: QuoteId,
30
+ status: Schema.Literal("held"),
31
+ }) {}
32
+
33
+ export class ItineraryHoldUnavailable extends Schema.TaggedErrorClass<ItineraryHoldUnavailable>()(
34
+ "ItineraryHoldUnavailable",
35
+ {
36
+ quoteId: QuoteId,
37
+ message: Schema.String,
38
+ },
39
+ ) {}
40
+
41
+ export class ItineraryHoldGateway extends Context.Service<
42
+ ItineraryHoldGateway,
43
+ {
44
+ readonly hold: (
45
+ request: ItineraryHoldRequest,
46
+ ) => Effect.Effect<ItineraryHold, ItineraryHoldUnavailable>;
47
+ }
48
+ >()("@effect-agent/testing/travel-planner/ItineraryHoldGateway") {}
49
+
50
+ /**
51
+ * The first mutating Travel Planner Tool. Effect AI marks it as approval-gated
52
+ * so the engine must settle approval before acquiring a handler permit.
53
+ */
54
+ export const HoldItinerary = Tool.make("hold_itinerary", {
55
+ parameters: ItineraryHoldRequest,
56
+ success: ItineraryHold,
57
+ failure: ItineraryHoldUnavailable,
58
+ failureMode: "error",
59
+ dependencies: [ItineraryHoldGateway],
60
+ needsApproval: true,
61
+ });
62
+
63
+ export const TravelPlannerPhase2Toolkit = Toolkit.make(
64
+ SearchFlights,
65
+ SearchLodging,
66
+ SearchActivities,
67
+ HoldItinerary,
68
+ );
69
+
70
+ export const TravelPlannerPhase2ToolkitLayer = TravelPlannerPhase2Toolkit.toLayer({
71
+ search_flights: (query) => Effect.flatMap(FlightCatalog, (catalog) => catalog.search(query)),
72
+ search_lodging: (query) => Effect.flatMap(LodgingCatalog, (catalog) => catalog.search(query)),
73
+ search_activities: (query) => Effect.flatMap(ActivityCatalog, (catalog) => catalog.search(query)),
74
+ hold_itinerary: (request) =>
75
+ Effect.flatMap(ItineraryHoldGateway, (gateway) => gateway.hold(request)),
76
+ });
77
+
78
+ export const TravelPlannerPhase2 = Agent.define("travel-planner-phase-2", {
79
+ input: TripRequest,
80
+ output: TravelPlan,
81
+ instructions: (input) =>
82
+ Effect.flatMap(TravelGuidance, (guidance) => guidance.instructions(input)),
83
+ toolkit: TravelPlannerPhase2Toolkit,
84
+ policy: AgentPolicy.make({
85
+ maxTurns: 3,
86
+ maxToolCalls: 4,
87
+ maxDuration: "30 seconds",
88
+ toolConcurrency: 3,
89
+ tokenBudget: 2_048,
90
+ }),
91
+ description:
92
+ "Build a review-only itinerary and require approval before creating a temporary hold.",
93
+ metadata: { deploymentClass: "E", phase: "P2" },
94
+ });
@@ -0,0 +1,159 @@
1
+ import { AgentId, ConversationId, RunId, SubmissionId } from "@effect-agent/core";
2
+ import {
3
+ BatchId,
4
+ CanonicalBatch,
5
+ ConversationCheckpoint,
6
+ ConversationProjection,
7
+ DefinitionDigests,
8
+ DeploymentId,
9
+ Digest,
10
+ ProducerId,
11
+ RecordEnvelope,
12
+ RecordId,
13
+ } from "@effect-agent/session";
14
+ import { Effect, Schema } from "effect";
15
+
16
+ import { TravelPlan, TripRequest } from "./definition.ts";
17
+ import { expectedTravelPlan, phase1Trip } from "./scenarios.ts";
18
+
19
+ /**
20
+ * The Phase 3 profile persists Conversation history but deliberately does not
21
+ * claim durable admission or recovery of accepted work.
22
+ */
23
+ export class TravelPlannerPersistenceProfile extends Schema.Class<TravelPlannerPersistenceProfile>(
24
+ "@effect-agent/testing/travel-planner/TravelPlannerPersistenceProfile",
25
+ )({
26
+ deploymentClass: Schema.Literal("P"),
27
+ durableAcceptedWork: Schema.Literal(false),
28
+ canonicalSchemaVersion: Schema.Literal(1),
29
+ }) {}
30
+
31
+ export const phase3TravelPlannerProfile = TravelPlannerPersistenceProfile.make({
32
+ deploymentClass: "P",
33
+ durableAcceptedWork: false,
34
+ canonicalSchemaVersion: 1,
35
+ });
36
+
37
+ export const phase3TravelPlannerConversationId = Schema.decodeSync(ConversationId)(
38
+ "travel-planner-p3-conversation",
39
+ );
40
+ export const phase3TravelPlannerProducerId = Schema.decodeSync(ProducerId)(
41
+ "travel-planner-p3-producer",
42
+ );
43
+ export const phase3TravelPlannerRunId = Schema.decodeSync(RunId)("travel-planner-p3-run");
44
+
45
+ const deploymentId = Schema.decodeSync(DeploymentId)("travel-planner-p3-scripted");
46
+ const agentId = Schema.decodeSync(AgentId)("travel-planner");
47
+ const submissionId = Schema.decodeSync(SubmissionId)("travel-planner-p3-submission");
48
+
49
+ const digest = (character: string) => Schema.decodeSync(Digest)(character.repeat(64));
50
+
51
+ /** Redacted, deterministic definition identities for the current fixture version. */
52
+ export const phase3TravelPlannerDefinitionDigests = DefinitionDigests.make({
53
+ agent: digest("a"),
54
+ model: digest("b"),
55
+ tools: digest("c"),
56
+ });
57
+
58
+ const tripInput = Schema.encodeSync(TripRequest)(phase1Trip);
59
+ const travelPlanOutput = Schema.encodeSync(TravelPlan)(expectedTravelPlan);
60
+
61
+ const record = (recordId: string, createdAt: string, payload: unknown) =>
62
+ Schema.decodeUnknownSync(RecordEnvelope)({
63
+ recordId: Schema.decodeSync(RecordId)(recordId),
64
+ family: "conversation",
65
+ schemaVersion: 1,
66
+ createdAt,
67
+ deploymentId,
68
+ payload,
69
+ });
70
+
71
+ /**
72
+ * The first atomic append establishes the Conversation and records its input.
73
+ * Its encoded value is the redacted current-version persistence fixture.
74
+ */
75
+ export const phase3TravelPlannerInitialBatch = CanonicalBatch.make({
76
+ batchId: Schema.decodeSync(BatchId)("travel-planner-p3-initial"),
77
+ producerId: phase3TravelPlannerProducerId,
78
+ records: [
79
+ record("travel-planner-p3-created", "2026-09-01T00:00:00.000Z", {
80
+ _tag: "ConversationCreated",
81
+ agentId,
82
+ definitions: phase3TravelPlannerDefinitionDigests,
83
+ }),
84
+ record("travel-planner-p3-input", "2026-09-01T00:00:01.000Z", {
85
+ _tag: "UserInputRecorded",
86
+ submissionId,
87
+ kind: "user",
88
+ runId: phase3TravelPlannerRunId,
89
+ input: tripInput,
90
+ }),
91
+ ],
92
+ });
93
+
94
+ /** The second append records the Schema-decoded itinerary and terminal Run result. */
95
+ export const phase3TravelPlannerCompletionBatch = CanonicalBatch.make({
96
+ batchId: Schema.decodeSync(BatchId)("travel-planner-p3-completion"),
97
+ producerId: phase3TravelPlannerProducerId,
98
+ records: [
99
+ record("travel-planner-p3-model", "2026-09-01T00:00:02.000Z", {
100
+ _tag: "ModelCompleted",
101
+ runId: phase3TravelPlannerRunId,
102
+ output: travelPlanOutput,
103
+ }),
104
+ record("travel-planner-p3-completed", "2026-09-01T00:00:03.000Z", {
105
+ _tag: "RunCompleted",
106
+ runId: phase3TravelPlannerRunId,
107
+ output: travelPlanOutput,
108
+ }),
109
+ ],
110
+ });
111
+
112
+ export const phase3TravelPlannerBatches = [
113
+ phase3TravelPlannerInitialBatch,
114
+ phase3TravelPlannerCompletionBatch,
115
+ ] as const;
116
+
117
+ /** Portable current-version fixture; it contains no passenger identity or credentials. */
118
+ export const phase3TravelPlannerEncodedFixture = Schema.encodeSync(Schema.Array(CanonicalBatch))(
119
+ phase3TravelPlannerBatches,
120
+ );
121
+
122
+ export class TravelPlannerProjectionError extends Schema.TaggedErrorClass<TravelPlannerProjectionError>()(
123
+ "TravelPlannerProjectionError",
124
+ { message: Schema.String },
125
+ ) {}
126
+
127
+ /** Decode the itinerary projection rebuilt from canonical model-completion records. */
128
+ export const travelPlanFromProjection = (
129
+ projection: ConversationProjection,
130
+ ): Effect.Effect<TravelPlan, TravelPlannerProjectionError> => {
131
+ const output = projection.modelOutputs.at(-1);
132
+ if (output === undefined) {
133
+ return Effect.fail(
134
+ TravelPlannerProjectionError.make({
135
+ message: "The canonical projection has no completed Travel Planner model output.",
136
+ }),
137
+ );
138
+ }
139
+ return Schema.decodeUnknownEffect(TravelPlan)(output).pipe(
140
+ Effect.mapError((error) => TravelPlannerProjectionError.make({ message: error.message })),
141
+ );
142
+ };
143
+
144
+ /** Build a disposable checkpoint bound to a validated canonical prefix. */
145
+ export const makePhase3TravelPlannerCheckpoint = (
146
+ projection: ConversationProjection,
147
+ ): ConversationCheckpoint =>
148
+ Schema.decodeSync(ConversationCheckpoint)({
149
+ schemaVersion: 1,
150
+ conversationId: projection.conversationId,
151
+ throughSequence: projection.throughSequence,
152
+ tailDigest: projection.tailDigest,
153
+ engineVersion: "phase-3-test-runtime",
154
+ agentDefinitionDigest: phase3TravelPlannerDefinitionDigests.agent,
155
+ modelDigest: phase3TravelPlannerDefinitionDigests.model,
156
+ toolDigest: phase3TravelPlannerDefinitionDigests.tools,
157
+ state: Schema.encodeSync(ConversationProjection)(projection),
158
+ createdAt: "2026-09-01T00:00:04.000Z",
159
+ });