Skip to content
Source
Sign inSign up
Open History to see the last commit

OrchestratorReplayRecovery.integration.test.ts

616 lines24.4 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as SqlClient from "effect/sql/SqlClient";
10import * as CodexReplay from "effect-codex-app-server/replay";
11import {
12 type OrchestrationV2ThreadProjection,
13 ProviderDriverKind,
14 type ThreadId,
15} from "@t3tools/contracts";
16​
17import {
18 ClaudeOrchestratorReplayHarness,
19 makeClaudeRestartReplayHarness,
20} from "../Adapters/ClaudeAdapterV2.testkit.ts";
21import { CodexOrchestratorReplayHarness } from "../Adapters/CodexAdapterV2.testkit.ts";
22import * as CodexAdapterV2Testkit from "../Adapters/CodexAdapterV2.testkit.ts";
23import {
24 type CursorAgentSdkReplayTranscript,
25 CursorOrchestratorReplayHarness,
26 makeCursorAgentSdkReplayRunner,
27} from "../Adapters/CursorAdapterV2.testkit.ts";
28import * as CursorAdapterV2Testkit from "../Adapters/CursorAdapterV2.testkit.ts";
29import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
30import * as EffectWorker from "../EffectWorker.ts";
31import * as SqlitePersistence from "../../persistence/Sqlite.ts";
32import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
33import {
34 CLAUDE_MODEL_SELECTION,
35 CODEX_MODEL_SELECTION,
36 CURSOR_MODEL_SELECTION,
37 materializeFixtureInput,
38 type MaterializedOrchestratorFixtureInput,
39 PROVIDER_THREAD_RESUME_FIRST_PROMPT,
40 PROVIDER_THREAD_RESUME_SECOND_PROMPT,
41} from "./fixtures/shared.ts";
42import {
43 assertAssistantTextIncludes,
44 assertBaseProjection,
45 assertConversationMessageRoles,
46 assertRunOrdinals,
47 assertSemanticProjectionIntegrity,
48 assertTurnItemTypes,
49 assertUserMessagesInclude,
50 projectionFor,
51} from "./fixtures/shared.ts";
52import {
53 runOrchestratorV2Scenario,
54 type OrchestratorV2ScenarioResult,
55} from "./OrchestratorScenario.ts";
56import {
57 layerProviderReplay,
58 runOrchestratorV2ProviderReplayScenario,
59} from "./ProviderReplayHarness.ts";
60import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
61import { materializeReplayTranscriptRuntimeInstructions } from "./ReplayRuntimeInstructions.ts";
62import {
63 materializeReplayTranscriptWorkspace,
64 readProviderReplayTranscript,
65} from "@t3tools/provider-testing/replayTranscript";
66​
67const FIRST_FINAL = "provider thread resume fixture first turn complete";
68const SECOND_FINAL = "provider thread resume fixture second turn complete";
69​
70const decodeCodexTranscript = Schema.decodeUnknownEffect(
71 CodexReplay.CodexAppServerReplayTranscript,
72);
73const readRawTranscript = Effect.fn("readRecoveryTranscript")(function* (file: URL) {
74 return yield* readProviderReplayTranscript(file);
75});
76const readCodexTranscript = Effect.fn("readCodexRecoveryTranscript")(function* (workspace: string) {
77 const transcript = yield* readRawTranscript(
78 new URL("./fixtures/provider_thread_resume/codex_transcript.ndjson", import.meta.url),
79 );
80 return yield* decodeCodexTranscript(materializeReplayTranscriptWorkspace(transcript, workspace));
81});
82const decodePromptPair = Schema.decodeUnknownEffect(Schema.Tuple([Schema.String, Schema.String]));
83const readClaudeSubagentResumeTranscript = Effect.fn("readClaudeSubagentResumeTranscript")(
84 function* () {
85 return yield* ClaudeOrchestratorReplayHarness.decodeTranscript(
86 yield* readRawTranscript(
87 new URL(
88 "./fixtures/claude_subagent_resume_after_restart/claude_transcript.ndjson",
89 import.meta.url,
90 ),
91 ),
92 );
93 },
94);
95/** Remove the subagent's first run so restart cancels it after root settlement; resume carries the restart note. */
96const readClaudeCancelledSubagentResumeTranscript = Effect.fn(
97 "readClaudeCancelledSubagentResumeTranscript",
98)(function* () {
99 const recorded = yield* readRawTranscript(
100 new URL(
101 "./fixtures/claude_subagent_resume_after_restart/claude_transcript.ndjson",
102 import.meta.url,
103 ),
104 );
105 const frameField = (index: number, key: string): unknown => {
106 const entry = recorded.entries[index];
107 return entry?.type === "emit_inbound" && typeof entry.frame === "object" && entry.frame !== null
108 ? Reflect.get(entry.frame, key)
109 : undefined;
110 };
111 const indices = recorded.entries.map((_, index) => index);
112 const firstSubagentFrame = indices.find(
113 (index) => typeof frameField(index, "parent_tool_use_id") === "string",
114 );
115 const firstOutcome = indices.find(
116 (index) => frameField(index, "subtype") === "task_notification",
117 );
118 const firstExit = recorded.entries.findIndex((entry) => entry.type === "runtime_exit");
119 if (firstSubagentFrame === undefined || firstOutcome === undefined || firstExit < 0) {
120 throw new Error("Expected the recorded subagent's first run.");
121 }
122 const entries = recorded.entries
123 // A CLI exit settles the task; hold at query.open:2 until the crash snapshot instead.
124 .filter(
125 (_, index) => (index < firstSubagentFrame || index > firstOutcome) && index !== firstExit,
126 )
127 .map((entry) => {
128 if (entry.type !== "expect_outbound" || entry.label !== "prompt.offer:2") return entry;
129 const frame = entry.frame as {
130 readonly message: { readonly message: { readonly content: string } };
131 };
132 return {
133 ...entry,
134 frame: {
135 ...frame,
136 message: {
137 ...frame.message,
138 message: {
139 ...frame.message.message,
140 content: [
141 "Note: the T3 server restarted, and this background work was cancelled before it finished. It will not report back on its own, so resume or restart what you still need and wait for its result:",
142 "- subagent: Audit src/math.ts (id a44c0ed810da9e4f3)",
143 "",
144 "User message:",
145 frame.message.message.content,
146 ].join("\n"),
147 },
148 },
149 },
150 };
151 });
152 return yield* ClaudeOrchestratorReplayHarness.decodeTranscript({ ...recorded, entries });
153});
154const readCursorTranscript = Effect.fn("readCursorRecoveryTranscript")(function* () {
155 const transcript = yield* readRawTranscript(
156 new URL("./fixtures/provider_thread_resume/cursor_transcript.ndjson", import.meta.url),
157 );
158 return yield* CursorOrchestratorReplayHarness.decodeTranscript(
159 materializeReplayTranscriptRuntimeInstructions(transcript, {
160 driver: ProviderDriverKind.make("cursor"),
161 model: CURSOR_MODEL_SELECTION.model,
162 }),
163 );
164});
165​
166function splitAfterFirstIdle(materialized: MaterializedOrchestratorFixtureInput) {
167 const splitIndex = materialized.steps.findIndex((step) => step.type === "await_thread_idle");
168 if (splitIndex < 0) {
169 throw new Error("Expected fixture to contain await_thread_idle after the first turn.");
170 }
171​
172 const phase1Steps = materialized.steps.slice(0, splitIndex + 1);
173 const phase2Steps = materialized.steps.slice(splitIndex + 1);
174 return {
175 phase1Steps,
176 phase2Steps,
177 phase1Commands: phase1Steps.flatMap((step) => (step.type === "dispatch" ? [step.command] : [])),
178 phase2Commands: phase2Steps.flatMap((step) => (step.type === "dispatch" ? [step.command] : [])),
179 };
181​
182const runCursorRecovery = Effect.fn("runCursorRecovery")(function* (input: {
183 readonly transcript: CursorAgentSdkReplayTranscript;
184 readonly runner: ReturnType<typeof makeCursorAgentSdkReplayRunner>;
185}) {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const tempDir = yield* Effect.acquireRelease(
189 fs.makeTempDirectory({
190 prefix: "t3-orchestration-v2-cursor-recovery-",
191 }),
192 (directory) => fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie),
193 );
194 yield* fs.makeDirectory(tempDir, { recursive: true });
195 const dbPath = path.join(tempDir, "state.sqlite");
196 const materialized = yield* materializeFixtureInput({
197 scenario: "provider_thread_resume",
198 fixtureInput: {
199 steps: [
200 { type: "message", text: PROVIDER_THREAD_RESUME_FIRST_PROMPT },
201 { type: "message", text: PROVIDER_THREAD_RESUME_SECOND_PROMPT },
202 ],
203 },
204 driver: ProviderDriverKind.make("cursor"),
205 modelSelection: CURSOR_MODEL_SELECTION,
206 });
207 const { phase1Commands, phase1Steps, phase2Commands, phase2Steps } =
208 splitAfterFirstIdle(materialized);
209 const options = {
210 databaseLayer: SqlitePersistence.layerFromPath(dbPath).pipe(
211 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
212 ),
213 };
214 const harness = {
215 ...CursorOrchestratorReplayHarness,
216 makeProviderAdapterRegistryLayer: () =>
217 CursorAdapterV2Testkit.layer(input.transcript, {
218 runner: input.runner,
219 assertCompleteOnFinalize: false,
220 }),
221 };
222​
223 yield* Effect.scoped(
224 runOrchestratorV2ProviderReplayScenario(
225 {
226 name: "provider_thread_resume/cursor:first-runtime",
227 transcript: input.transcript,
228 commands: phase1Commands,
229 steps: phase1Steps,
230 projectionThreadIds: materialized.projectionThreadIds,
231 runtimePolicyOverride: { cwd: tempDir },
232 },
233 harness,
234 options,
235 ),
236 );
237​
238 const result = yield* Effect.scoped(
239 runOrchestratorV2ProviderReplayScenario(
240 {
241 name: "provider_thread_resume/cursor:second-runtime",
242 transcript: input.transcript,
243 commands: phase2Commands,
244 steps: phase2Steps,
245 projectionThreadIds: materialized.projectionThreadIds,
246 runtimePolicyOverride: { cwd: tempDir },
247 },
248 harness,
249 // A real restart requeues effects, like checkpoint capture, the first runtime left running.
250 { ...options, recoverOnStartup: true },
251 ),
252 );
253​
254 assertBaseProjection({
255 result,
256 transcript: input.transcript,
257 runCount: 2,
258 runStatuses: ["completed", "completed"],
259 });
260 const projection = projectionFor(result, input.transcript.scenario);
261 assertSemanticProjectionIntegrity(projection);
262 assertRunOrdinals(projection, [1, 2]);
263 assertConversationMessageRoles(projection, ["user", "assistant", "user", "assistant"]);
264 assertTurnItemTypes(projection, ["user_message", "assistant_message"]);
265 assertUserMessagesInclude(projection, [
266 PROVIDER_THREAD_RESUME_FIRST_PROMPT,
267 PROVIDER_THREAD_RESUME_SECOND_PROMPT,
268 ]);
269 assertAssistantTextIncludes(projection, FIRST_FINAL);
270 assertAssistantTextIncludes(projection, SECOND_FINAL);
271 assert.lengthOf(projection.providerThreads, 1);
272});
273​
274describe("orchestrator replay recovery", () => {
275 it.effect(
276 "resumes a provider-native Codex thread after recreating the orchestrator runtime",
277 () =>
278 Effect.gen(function* () {
279 // Use a throwaway checkpoint workspace; cold full-repo capture exceeds CI wait budgets.
280 const workspace = yield* checkpointWorkspace("provider_thread_resume");
281 const transcript = yield* readCodexTranscript(workspace);
282 const fs = yield* FileSystem.FileSystem;
283 const path = yield* Path.Path;
284 const tempDir = yield* Effect.acquireRelease(
285 fs.makeTempDirectory({
286 prefix: "t3-orchestration-v2-recovery-",
287 }),
288 (directory) => fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie),
289 );
290 yield* fs.makeDirectory(tempDir, { recursive: true });
291 const dbPath = path.join(tempDir, "state.sqlite");
292 const driver = yield* CodexReplay.makeReplayDriver(transcript);
293 const materialized = yield* materializeFixtureInput({
294 scenario: "provider_thread_resume",
295 fixtureInput: {
296 steps: [
297 { type: "message", text: PROVIDER_THREAD_RESUME_FIRST_PROMPT },
298 { type: "message", text: PROVIDER_THREAD_RESUME_SECOND_PROMPT },
299 ],
300 },
301 driver: ProviderDriverKind.make("codex"),
302 modelSelection: CODEX_MODEL_SELECTION,
303 });
304 const { phase1Commands, phase1Steps, phase2Commands, phase2Steps } =
305 splitAfterFirstIdle(materialized);
306​
307 const harness = {
308 ...CodexOrchestratorReplayHarness,
309 makeProviderAdapterRegistryLayer: () =>
310 CodexAdapterV2Testkit.layer({ transcript, driver }),
311 };
312 const options = {
313 databaseLayer: SqlitePersistence.layerFromPath(dbPath).pipe(
314 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
315 ),
316 };
317​
318 yield* runOrchestratorV2ProviderReplayScenario(
319 {
320 name: "provider_thread_resume/codex:first-runtime",
321 transcript,
322 commands: phase1Commands,
323 steps: phase1Steps,
324 projectionThreadIds: materialized.projectionThreadIds,
325 runtimePolicyOverride: { cwd: workspace },
326 },
327 harness,
328 options,
329 );
330​
331 const result = yield* runOrchestratorV2ProviderReplayScenario(
332 {
333 name: "provider_thread_resume/codex:second-runtime",
334 transcript,
335 commands: phase2Commands,
336 steps: phase2Steps,
337 projectionThreadIds: materialized.projectionThreadIds,
338 runtimePolicyOverride: { cwd: workspace },
339 },
340 harness,
341 { ...options, recoverOnStartup: true },
342 );
343​
344 assertBaseProjection({
345 result,
346 transcript,
347 runCount: 2,
348 runStatuses: ["completed", "completed"],
349 });
350 const projection = projectionFor(result, transcript.scenario);
351 assertSemanticProjectionIntegrity(projection);
352 assertRunOrdinals(projection, [1, 2]);
353 assertConversationMessageRoles(projection, ["user", "assistant", "user", "assistant"]);
354 assertTurnItemTypes(projection, ["user_message", "assistant_message"]);
355 assertUserMessagesInclude(projection, [
356 PROVIDER_THREAD_RESUME_FIRST_PROMPT,
357 PROVIDER_THREAD_RESUME_SECOND_PROMPT,
358 ]);
359 assertAssistantTextIncludes(projection, FIRST_FINAL);
360 assertAssistantTextIncludes(projection, SECOND_FINAL);
361 assert.lengthOf(projection.providerThreads, 1);
362 }).pipe(
363 provideDeterministicTestRuntime,
364 Effect.provide(
365 Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
366 ),
367 ),
368 );
369​
370 it.effect(
371 "resumes a provider-native Cursor thread after recreating the orchestrator runtime",
372 () =>
373 Effect.gen(function* () {
374 const transcript = yield* readCursorTranscript();
375 const runner = makeCursorAgentSdkReplayRunner(transcript);
376 yield* runCursorRecovery({ transcript, runner });
377 yield* runner.assertComplete;
378 }).pipe(
379 provideDeterministicTestRuntime,
380 Effect.provide(
381 Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
382 ),
383 ),
384 );
385 it.effect("keeps a Claude subagent resumed after a server restart in its own thread", () =>
386 Effect.gen(function* () {
387 const transcript = yield* readClaudeSubagentResumeTranscript();
388 const [launchPrompt, resumePrompt] = yield* decodePromptPair(transcript.metadata?.prompts);
389 const workspace = yield* checkpointWorkspace(transcript.scenario);
390 const fs = yield* FileSystem.FileSystem;
391 const path = yield* Path.Path;
392 const tempDir = yield* Effect.acquireRelease(
393 fs.makeTempDirectory({ prefix: "t3-orchestration-v2-claude-recovery-" }),
394 (directory) => fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie),
395 );
396 const materialized = yield* materializeFixtureInput({
397 scenario: transcript.scenario,
398 fixtureInput: {
399 steps: [
400 { type: "message", text: launchPrompt },
401 { type: "message", text: resumePrompt },
402 ],
403 },
404 driver: ProviderDriverKind.make("claudeAgent"),
405 modelSelection: CLAUDE_MODEL_SELECTION,
406 });
407 const { phase1Commands, phase1Steps, phase2Commands, phase2Steps } =
408 splitAfterFirstIdle(materialized);
409 const { harness, assertComplete } = makeClaudeRestartReplayHarness(transcript);
410 const options = {
411 databaseLayer: SqlitePersistence.layerFromPath(path.join(tempDir, "state.sqlite")).pipe(
412 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
413 ),
414 };
415​
416 yield* Effect.scoped(
417 runOrchestratorV2ProviderReplayScenario(
418 {
419 name: `${transcript.scenario}:first-runtime`,
420 transcript,
421 commands: phase1Commands,
422 steps: phase1Steps,
423 projectionThreadIds: materialized.projectionThreadIds,
424 runtimePolicyOverride: { cwd: workspace },
425 },
426 harness,
427 options,
428 ),
429 );
430 const result = yield* Effect.scoped(
431 runOrchestratorV2ProviderReplayScenario(
432 {
433 name: `${transcript.scenario}:second-runtime`,
434 transcript,
435 commands: phase2Commands,
436 steps: phase2Steps,
437 projectionThreadIds: materialized.projectionThreadIds,
438 runtimePolicyOverride: { cwd: workspace },
439 },
440 harness,
441 { ...options, recoverOnStartup: true },
442 ),
443 );
444 yield* assertComplete;
445​
446 const projection = projectionFor(result, transcript.scenario);
447 assertSemanticProjectionIntegrity(projection);
448 assert.lengthOf(projection.subagents, 1);
449 const subagent = projection.subagents[0];
450 assert.equal(subagent?.status, "completed");
451 const childThreads = [...result.projections.values()].filter(
452 (candidate) => candidate.thread.lineage.parentThreadId === projection.thread.id,
453 );
454 assert.lengthOf(childThreads, 1);
455 const child = childThreads[0];
456 assert.equal(child?.thread.id, subagent?.childThreadId);
457 assert.deepEqual(child?.thread.modelSelection, {
458 instanceId: CLAUDE_MODEL_SELECTION.instanceId,
459 model: "claude-sonnet-5",
460 });
461​
462 const conversation = (child?.turnItems ?? [])
463 .toSorted((left, right) => left.ordinal - right.ordinal)
464 .flatMap((item) =>
465 item.type === "user_message" || item.type === "assistant_message"
466 ? [`${item.type === "user_message" ? "user" : "assistant"}:${item.text}`]
467 : [],
468 );
469 const launchTask = conversation[0];
470 assert.deepEqual(
471 conversation.map((entry) => entry.slice(0, entry.indexOf(":"))),
472 ["user", "assistant", "user", "assistant"],
473 );
474 assert.include(launchTask, "Your job is just the file");
475 assert.include(conversation[1], "is 1 line long");
476 assert.include(conversation[2], "Look again at");
477 assert.include(conversation[3], "Bug/edge case found and fixed");
478 const childCommands = (child?.turnItems ?? []).filter(
479 (item) => item.type === "command_execution",
480 );
481 assert.lengthOf(childCommands, 3);
482 assert.isFalse(
483 projection.turnItems.some(
484 (item) => item.type === "assistant_message" && item.text.includes("Bug/edge case found"),
485 ),
486 "the resumed subagent's reply leaked into the parent thread",
487 );
488 }).pipe(
489 provideDeterministicTestRuntime,
490 Effect.provide(
491 Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
492 ),
493 ),
494 );
495 it.effect("reopens the thread of a Claude subagent the restart cancelled when resumed", () =>
496 Effect.gen(function* () {
497 const transcript = yield* readClaudeCancelledSubagentResumeTranscript();
498 const [launchPrompt, resumePrompt] = yield* decodePromptPair(transcript.metadata?.prompts);
499 const workspace = yield* checkpointWorkspace(transcript.scenario);
500 const fs = yield* FileSystem.FileSystem;
501 const path = yield* Path.Path;
502 const tempDir = yield* Effect.acquireRelease(
503 fs.makeTempDirectory({ prefix: "t3-orchestration-v2-claude-recovery-" }),
504 (directory) => fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie),
505 );
506 const materialized = yield* materializeFixtureInput({
507 scenario: transcript.scenario,
508 fixtureInput: {
509 steps: [
510 { type: "message", text: launchPrompt },
511 { type: "message", text: resumePrompt },
512 ],
513 },
514 driver: ProviderDriverKind.make("claudeAgent"),
515 modelSelection: CLAUDE_MODEL_SELECTION,
516 });
517 const { phase1Commands, phase1Steps, phase2Commands, phase2Steps } =
518 splitAfterFirstIdle(materialized);
519 const { harness, assertComplete } = makeClaudeRestartReplayHarness(transcript);
520 const options = {
521 databaseLayer: SqlitePersistence.layerFromPath(path.join(tempDir, "state.sqlite")).pipe(
522 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
523 ),
524 };
525 const childThreadOf = (result: OrchestratorV2ScenarioResult, parentThreadId: ThreadId) =>
526 [...result.projections.values()].find(
527 (candidate) => candidate.thread.lineage.parentThreadId === parentThreadId,
528 );
529 const childRootStatus = (child: OrchestrationV2ThreadProjection | undefined) =>
530 child?.nodes.find((node) => node.kind === "root_turn" && node.runId === null)?.status;
531​
532 const snapshotPath = path.join(tempDir, "restart-state.sqlite");
533 const beforeScenario = {
534 name: `${transcript.scenario}:cancelled-first-runtime`,
535 transcript,
536 commands: phase1Commands,
537 steps: phase1Steps,
538 projectionThreadIds: materialized.projectionThreadIds,
539 runtimePolicyOverride: { cwd: workspace },
540 };
541 const before = yield* Effect.scoped(
542 Effect.gen(function* () {
543 const result = yield* runOrchestratorV2Scenario(beforeScenario);
544 const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
545 yield* worker.drain();
546 // Preserve the orphan before scoped teardown settles its provider work.
547 const sql = yield* SqlClient.SqlClient;
548 yield* sql`VACUUM INTO ${snapshotPath}`;
549 return result;
550 }).pipe(
551 Effect.provide(
552 Layer.merge(
553 layerProviderReplay(beforeScenario, harness, options),
554 options.databaseLayer,
555 ),
556 ),
557 ),
558 );
559 const settled = projectionFor(before, transcript.scenario);
560 assert.equal(settled.subagents[0]?.status, "running");
561 assert.equal(childRootStatus(childThreadOf(before, settled.thread.id)), "running");
562​
563 const result = yield* Effect.scoped(
564 runOrchestratorV2ProviderReplayScenario(
565 {
566 name: `${transcript.scenario}:cancelled-second-runtime`,
567 transcript,
568 commands: phase2Commands,
569 steps: phase2Steps,
570 projectionThreadIds: materialized.projectionThreadIds,
571 runtimePolicyOverride: { cwd: workspace },
572 },
573 harness,
574 {
575 databaseLayer: SqlitePersistence.layerFromPath(snapshotPath).pipe(
576 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
577 ),
578 recoverOnStartup: true,
579 continueThreadsAfterServerUpdate: false,
580 },
581 ),
582 );
583 yield* assertComplete;
584​
585 const projection = projectionFor(result, transcript.scenario);
586 assertSemanticProjectionIntegrity(projection);
587 assert.equal(projection.subagents[0]?.status, "completed");
588 const child = childThreadOf(result, projection.thread.id);
589 assert.equal(child?.thread.id, projection.subagents[0]?.childThreadId);
590 assert.equal(childRootStatus(child), "completed");
591 const conversation = (child?.turnItems ?? [])
592 .toSorted((left, right) => left.ordinal - right.ordinal)
593 .flatMap((item) =>
594 item.type === "user_message" || item.type === "assistant_message"
595 ? [`${item.type === "user_message" ? "user" : "assistant"}:${item.text}`]
596 : [],
597 );
598 assert.deepEqual(
599 conversation.map((entry) => entry.slice(0, entry.indexOf(":"))),
600 ["user", "user", "assistant"],
601 );
602 assert.include(conversation[0], "Your job is just the file");
603 assert.include(conversation[1], "Look again at");
604 assert.include(conversation[2], "Bug/edge case found and fixed");
605 assert.lengthOf(
606 (child?.turnItems ?? []).filter((item) => item.type === "command_execution"),
607 2,
608 );
609 }).pipe(
610 provideDeterministicTestRuntime,
611 Effect.provide(
612 Layer.merge(IdAllocator.layer, Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
613 ),
614 ),
615 );
616});