1/**
2 * Records a Grok ACP replay transcript from a live `grok agent stdio` process.
3 *
4 * The fixture's own scenario (the same commands and steps the replay test
5 * dispatches) runs through the real orchestrator and the real GrokAdapterV2;
6 * only the ACP runtime's protocol logger is swapped for a tee. Outbound frames
7 * are therefore exactly what T3 sends, and inbound frames exactly what Grok
8 * answered. Run from apps/server with the Grok CLI on PATH (or T3_GROK_BIN):
9 *
10 * node scripts/record-grok-acp-replay-fixture.ts --scenario simple
11 */
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { type ProviderReplayEntry } from "@t3tools/contracts";
14import { GrokSettings } from "@t3tools/provider-grok/settings";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
17import * as Clock from "effect/Clock";
18import * as Console from "effect/Console";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24import type * as EffectAcpProtocol from "effect-acp/protocol";
25
26import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
27import {
28 GROK_DEFAULT_INSTANCE_ID,
29 GROK_PROVIDER,
30 grokLaunchRuntimeMode,
31 makeGrokAdapterV2,
32} from "@t3tools/provider-grok/testing";
33import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter";
34import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
35import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
36import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
37import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts";
38import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts";
39import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";
40import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
41import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts";
42import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
43import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
44import { makeGrokAcpRuntime } from "@t3tools/provider-grok/testing";
45import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
46
47const wallClock = Clock.Clock.defaultValue();
48const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
49const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
50
51// Broadcasts T3 never reads: account tier, marketing, and client UI settings.
52const DROPPED_INBOUND_METHODS = new Set(["_x.ai/settings/update", "_x.ai/announcements/update"]);
53const HOME_PLACEHOLDER = "/home/grok-replay";
54
55interface JsonRpcMessage {
56 readonly id?: string | number | null;
57 readonly method?: string;
58 readonly params?: unknown;
59 readonly result?: unknown;
60 readonly error?: unknown;
61}
62
63interface WireMessage {
64 readonly direction: "incoming" | "outgoing";
65 readonly message: JsonRpcMessage;
66}
67
68function readArgValues(name: string): ReadonlyArray<string> {
69 const args = process.argv.slice(2);
70 return args.flatMap((arg, index) => (arg === name && args[index + 1] ? [args[index + 1]!] : []));
71}
72
73function isRecord(value: unknown): value is Record<string, unknown> {
74 return typeof value === "object" && value !== null && !Array.isArray(value);
75}
76
77function mapStrings(value: unknown, map: (text: string) => string): unknown {
78 if (typeof value === "string") return map(value);
79 if (Array.isArray(value)) return value.map((entry) => mapStrings(entry, map));
80 if (!isRecord(value)) return value;
81 return Object.fromEntries(
82 Object.entries(value).map(([key, entry]) => [key, mapStrings(entry, map)]),
83 );
84}
85
86/** Tees raw ACP lines in wire order. Replay drives one process, so a restart is an error. */
87function makeWireTee() {
88 const wire: Array<WireMessage> = [];
89 let runtimeCount = 0;
90 const attachRuntime = () => {
91 runtimeCount += 1;
92 if (runtimeCount > 1) {
93 throw new Error("The Grok recording spawned a second ACP process; replay drives one.");
94 }
95 let incomingBuffer = "";
96 const push = (direction: WireMessage["direction"], line: string) => {
97 if (line.trim().length === 0) return;
98 wire.push({ direction, message: decodeJson(line) as JsonRpcMessage });
99 };
100 return {
101 logIncoming: true,
102 logOutgoing: true,
103 logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
104 Effect.sync(() => {
105 if (event.stage !== "raw" || typeof event.payload !== "string") return;
106 if (event.direction === "outgoing") {
107 for (const line of event.payload.split("\n")) push("outgoing", line);
108 return;
109 }
110 incomingBuffer += event.payload;
111 const lines = incomingBuffer.split("\n");
112 incomingBuffer = lines.pop() ?? "";
113 for (const line of lines) push("incoming", line);
114 }),
115 };
116 };
117 // Grok runs its own wake turns (`task-completed-*`, `notifications-*`,
118 // `subagent-completed-*`) after T3's run can already have settled, and its
119 // session roster can report idle before them. Grok is done when no session
120 // has a queued or running prompt (`x.ai/queue/changed` vs `turn_completed`),
121 // no background task still runs (`background_tasks`), and every spawned
122 // subagent finished (`subagent_spawned` vs `subagent_finished`).
123 const grokHasPendingWork = () => {
124 const queues = new Map<unknown, Record<string, unknown>>();
125 const tasks = new Map<unknown, ReadonlyArray<unknown>>();
126 const completedPrompts = new Set<unknown>();
127 const liveSubagents = new Set<unknown>();
128 for (const { direction, message } of wire) {
129 if (direction !== "incoming" || !isRecord(message.params)) continue;
130 const params = message.params;
131 const update = isRecord(params.update) ? params.update : undefined;
132 if (message.method === "_x.ai/queue/changed") queues.set(params.sessionId, params);
133 if (update?.sessionUpdate === "turn_completed") completedPrompts.add(update.prompt_id);
134 if (update?.sessionUpdate === "background_tasks" && Array.isArray(update.tasks)) {
135 tasks.set(params.sessionId, update.tasks);
136 }
137 if (update?.sessionUpdate === "subagent_spawned") liveSubagents.add(update.subagent_id);
138 if (update?.sessionUpdate === "subagent_finished") liveSubagents.delete(update.subagent_id);
139 }
140 const promptPending = [...queues.values()].some(
141 (queue) =>
142 (Array.isArray(queue.entries) && queue.entries.length > 0) ||
143 (queue.runningPromptId !== undefined && !completedPrompts.has(queue.runningPromptId)),
144 );
145 const taskRunning = [...tasks.values()].some((list) =>
146 list.some((task) => isRecord(task) && task.status === "running"),
147 );
148 return promptPending || taskRunning || liveSubagents.size > 0;
149 };
150 return { wire, attachRuntime, grokHasPendingWork };
151}
152
153function frameLabel(kind: string, method: string, params: unknown): string {
154 const update = isRecord(params) && isRecord(params.update) ? params.update : undefined;
155 const updateType = typeof update?.sessionUpdate === "string" ? `:${update.sessionUpdate}` : "";
156 // Frames carry the prompt they belong to, so a fixture can gate on the start
157 // of one of Grok's own wake turns (`task-completed-*`, `subagent-completed-*`).
158 const meta = isRecord(params) && isRecord(params._meta) ? params._meta : undefined;
159 const promptId =
160 update?.sessionUpdate === "turn_completed" && typeof update.prompt_id === "string"
161 ? update.prompt_id
162 : typeof meta?.promptId === "string"
163 ? meta.promptId
164 : undefined;
165 return `${kind}:${method}${updateType}${promptId === undefined ? "" : `:${promptId}`}`;
166}
167
168/** Pairs JSON-RPC ids with their methods and emits the logical frames acp-replay-agent reads. */
169function wireToEntries(wire: ReadonlyArray<WireMessage>): {
170 readonly entries: Array<ProviderReplayEntry>;
171 readonly droppedFrames: number;
172} {
173 const entries: Array<ProviderReplayEntry> = [];
174 const t3Requests = new Map<string, string>();
175 const agentRequests = new Map<string, string>();
176 let droppedFrames = 0;
177 for (const { direction, message } of wire) {
178 const type = direction === "outgoing" ? "expect_outbound" : "emit_inbound";
179 if (typeof message.method === "string") {
180 if (direction === "incoming" && DROPPED_INBOUND_METHODS.has(message.method)) {
181 droppedFrames += 1;
182 continue;
183 }
184 const isRequest = message.id !== undefined && message.id !== null;
185 if (isRequest) {
186 (direction === "outgoing" ? t3Requests : agentRequests).set(
187 String(message.id),
188 message.method,
189 );
190 }
191 const kind = isRequest ? "request" : "notification";
192 entries.push({
193 type,
194 label: frameLabel(kind, message.method, message.params),
195 frame: {
196 kind,
197 method: message.method,
198 ...(message.params === undefined ? {} : { params: message.params }),
199 },
200 });
201 continue;
202 }
203 const pending = direction === "outgoing" ? agentRequests : t3Requests;
204 const method = pending.get(String(message.id));
205 if (method === undefined) {
206 // Grok answers its own internal requests (e.g. id "skills-reload") on the
207 // shared stream; T3's protocol drops those, so replay never sees them.
208 droppedFrames += 1;
209 continue;
210 }
211 pending.delete(String(message.id));
212 entries.push({
213 type,
214 label: `response:${method}`,
215 frame: {
216 kind: "response",
217 method,
218 ...(message.result === undefined ? {} : { result: message.result }),
219 ...(message.error === undefined ? {} : { error: message.error }),
220 },
221 });
222 }
223 return { entries, droppedFrames };
224}
225
226const T3_INSTRUCTIONS_BODY = /<t3_code_instructions>\n[\s\S]*?\n<\/t3_code_instructions>/u;
227
228/** Replaces T3-owned request content so prompt wording changes do not invalidate recordings. */
229function normalizeOutboundFrame(frame: Record<string, unknown>, runtimeInstructions: string) {
230 const params = isRecord(frame.params) ? frame.params : undefined;
231 if (params === undefined) return frame;
232 switch (frame.method) {
233 case "initialize":
234 // Pin what T3 advertises (fs and terminal capabilities decide whether
235 // Grok routes file and shell work through T3, the client type whether
236 // Auto mode asks); the rest is <any>.
237 return {
238 ...frame,
239 params: Object.fromEntries(
240 Object.keys(params).map((key) => [
241 key,
242 key === "protocolVersion" || key === "clientCapabilities" || key === "_meta"
243 ? params[key]
244 : "<any>",
245 ]),
246 ),
247 };
248 case "session/new":
249 case "session/load":
250 case "session/resume":
251 return { ...frame, params: { ...params, mcpServers: "<any>" } };
252 case "session/prompt": {
253 if (!Array.isArray(params.prompt)) return frame;
254 const prompt = params.prompt.filter(
255 (part) => !(isRecord(part) && part.type === "text" && part.text === runtimeInstructions),
256 );
257 return {
258 ...frame,
259 params: {
260 ...params,
261 prompt: prompt.map((part) =>
262 isRecord(part) && typeof part.text === "string"
263 ? {
264 ...part,
265 text: part.text.replace(
266 T3_INSTRUCTIONS_BODY,
267 "<t3_code_instructions>\n<any>\n</t3_code_instructions>",
268 ),
269 }
270 : part,
271 ),
272 },
273 };
274 }
275 default:
276 return frame;
277 }
278}
279
280/** Removes machine identity and personal skills the recording machine advertised. */
281function normalizeInboundFrame(frame: Record<string, unknown>): Record<string, unknown> {
282 const params = isRecord(frame.params) ? frame.params : undefined;
283 const update = isRecord(params?.update) ? params.update : undefined;
284 if (
285 update?.sessionUpdate === "available_commands_update" &&
286 Array.isArray(update.availableCommands)
287 ) {
288 return {
289 ...frame,
290 params: {
291 ...params,
292 update: {
293 ...update,
294 availableCommands: update.availableCommands.filter(
295 (command) =>
296 !(isRecord(command) && isRecord(command._meta) && command._meta.scope === "user"),
297 ),
298 },
299 },
300 };
301 }
302 const result = isRecord(frame.result) ? frame.result : undefined;
303 if (frame.method === "initialize" && isRecord(result?._meta)) {
304 const meta = result._meta;
305 return {
306 ...frame,
307 result: {
308 ...result,
309 _meta: {
310 ...meta,
311 agentId: "00000000-0000-4000-8000-000000000000",
312 agentInstanceId: "00000000-0000-4000-8000-000000000000",
313 hostname: "grok-replay",
314 ...(Array.isArray(meta.availableCommands)
315 ? {
316 availableCommands: meta.availableCommands.filter(
317 (command) =>
318 !(
319 isRecord(command) &&
320 isRecord(command._meta) &&
321 command._meta.scope === "user"
322 ),
323 ),
324 }
325 : {}),
326 },
327 },
328 };
329 }
330 return frame;
331}
332
333const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu;
334
335/** Collects Grok session and background task ids in first-seen order. */
336function collectSessionIds(entries: ReadonlyArray<ProviderReplayEntry>): ReadonlyArray<string> {
337 const ids: Array<string> = [];
338 const add = (value: unknown) => {
339 if (typeof value === "string" && value.length > 0 && !ids.includes(value)) ids.push(value);
340 };
341 for (const entry of entries) {
342 if (entry.type === "runtime_exit" || !isRecord(entry.frame)) continue;
343 const frame = entry.frame;
344 if (frame.kind === "response" && frame.method === "session/new" && isRecord(frame.result)) {
345 add(frame.result.sessionId);
346 }
347 if (entry.type === "emit_inbound" && isRecord(frame.params)) {
348 add(frame.params.sessionId);
349 // Background task ids name Grok's wake turns (`task-completed-<id>`).
350 const update = isRecord(frame.params.update) ? frame.params.update : undefined;
351 if (update?.sessionUpdate === "task_backgrounded" && UUID.test(String(update.task_id))) {
352 add(update.task_id);
353 }
354 }
355 }
356 return ids;
357}
358
359function normalizeEntries(input: {
360 readonly entries: ReadonlyArray<ProviderReplayEntry>;
361 readonly workspace: string;
362 readonly home: string;
363 readonly user: string;
364 readonly runtimeInstructions: string;
365}): Array<ProviderReplayEntry> {
366 const sessionIds = collectSessionIds(input.entries);
367 const replacements: Array<readonly [string, string]> = [
368 ...sessionIds.map(
369 (id, index) =>
370 [id, `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`] as const,
371 ),
372 [input.workspace, "<workspace>"],
373 // Grok keys its session directory by the URL-encoded cwd.
374 [encodeURIComponent(input.workspace), "%3Cworkspace%3E"],
375 [input.home, HOME_PLACEHOLDER],
376 ];
377 // Shell output (e.g. `ls -l`) names the recording user.
378 const user = /^[a-z_][a-z0-9_-]*$/iu.test(input.user) ? input.user : "";
379 const userPattern = user.length === 0 ? undefined : new RegExp(`\\b${user}\\b`, "gu");
380 const replaceAll = (text: string) => {
381 const replaced = replacements.reduce(
382 (current, [from, to]) => (from.length === 0 ? current : current.replaceAll(from, to)),
383 text,
384 );
385 return userPattern === undefined ? replaced : replaced.replace(userPattern, "grok-replay");
386 };
387 return input.entries.map((entry) => {
388 if (entry.type === "runtime_exit" || !isRecord(entry.frame)) return entry;
389 const frame =
390 entry.type === "expect_outbound"
391 ? normalizeOutboundFrame(entry.frame, input.runtimeInstructions)
392 : normalizeInboundFrame(entry.frame);
393 return {
394 ...entry,
395 ...(entry.label === undefined ? {} : { label: replaceAll(entry.label) }),
396 frame: mapStrings(frame, replaceAll),
397 };
398 });
399}
400
401const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({});
402
403const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: string) {
404 const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName);
405 const variant = fixture?.providers.find((provider) => provider.driver === GROK_PROVIDER);
406 if (fixture === undefined || variant === undefined) {
407 return yield* Effect.die(new Error(`No Grok replay fixture named '${fixtureName}'.`));
408 }
409 const fs = yield* FileSystem.FileSystem;
410 const path = yield* Path.Path;
411 const fixtureInput = fixture.buildInput();
412 // Same README and seeded files as the replay workspace, so client-mediated
413 // fs reads answer identically at replay time.
414 const workspace = yield* checkpointWorkspace(fixtureName, fixtureInput.workspaceFiles);
415 const realWorkspace = yield* fs.realPath(workspace);
416 const materialized = yield* materializeFixtureInput({
417 scenario: fixtureName,
418 fixtureInput,
419 driver: GROK_PROVIDER,
420 modelSelection: variant.modelSelection,
421 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
422 const scenario = {
423 name: `${fixtureName}/grok-record`,
424 commands: materialized.commands,
425 // Background fixtures pace replay with gates and with receipts that drive
426 // the finish debounce on the test clock. Live, Grok and wall time pace
427 // themselves, so a fixture that gates records its dispatches only and then
428 // waits for Grok to idle, and a held run is simply waited for.
429 steps: materialized.steps.some(
430 (step) =>
431 step.type === "release_replay_gate" || step.type === "release_replay_gate_after_waiting",
432 )
433 ? materialized.steps.filter((step) => step.type === "dispatch")
434 : materialized.steps.map((step) =>
435 step.type === "finish_held_run" ? { ...step, type: "await_run_status" as const } : step,
436 ),
437 projectionThreadIds: materialized.projectionThreadIds,
438 runtimePolicyOverride: { ...variant.runtimePolicyOverride, cwd: realWorkspace },
439 };
440
441 const tee = makeWireTee();
442 const settings = { ...DEFAULT_GROK_SETTINGS, binaryPath: process.env.T3_GROK_BIN ?? "grok" };
443 const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect(
444 Effect.gen(function* () {
445 const environment = yield* HostProcess.Environment;
446 const adapter = yield* makeGrokAdapterV2({
447 instanceId: GROK_DEFAULT_INSTANCE_ID,
448 settings,
449 environment,
450 hostPlatform: yield* HostProcess.Platform,
451 selfInvocation: yield* resolveSelfInvocation(),
452 continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests,
453 // Production's runtime factory, with the protocol logger teeing raw lines.
454 makeRuntime: ({ runtimePolicy, ...input }) =>
455 makeGrokAcpRuntime({
456 ...input,
457 protocolLogging: tee.attachRuntime(),
458 interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
459 grokSettings: settings,
460 environment,
461 runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
462 }),
463 });
464 // The scenario runs on the replay TestClock so its clock steps order
465 // dispatches as replay will. Grok itself runs on wall time, so the
466 // adapter's session (finish debounces, safety holds) does too.
467 const onWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
468 Effect.provideService(effect, Clock.Clock, wallClock);
469 return [
470 {
471 ...adapter,
472 openSession: (input) =>
473 adapter.openSession(input).pipe(
474 Effect.map((session): ProviderAdapter.ProviderAdapterV2SessionRuntime => ({
475 ...session,
476 startTurn: (turnInput) => onWallClock(session.startTurn(turnInput)),
477 steerTurn: (turnInput) => onWallClock(session.steerTurn(turnInput)),
478 interruptTurn: (turnInput) => onWallClock(session.interruptTurn(turnInput)),
479 respondToRuntimeRequest: (turnInput) =>
480 onWallClock(session.respondToRuntimeRequest(turnInput)),
481 })),
482 onWallClock,
483 ),
484 },
485 ];
486 }),
487 ).pipe(
488 Layer.provide(
489 Layer.mergeAll(
490 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
491 NodeServices.layer,
492 IdAllocator.layer,
493 ),
494 ),
495 );
496
497 // Same deterministic runtime as replay (TestClock, seeded ids), so the
498 // scenario's clock steps order dispatches exactly as they will on replay.
499 // Background scenarios keep the session open until Grok's own wake turns
500 // finish, so the transcript holds everything replay will emit.
501 // A wake turn is admitted shortly after the frame that triggers it, so Grok
502 // must also stay quiet for a few seconds.
503 let quietPolls = 0;
504 let seenFrames = -1;
505 const waitForGrokIdle = Effect.sleep("1 second").pipe(
506 Effect.andThen(
507 Effect.sync(() => {
508 quietPolls = seenFrames === tee.wire.length ? quietPolls + 1 : 0;
509 seenFrames = tee.wire.length;
510 return quietPolls >= 5 && !tee.grokHasPendingWork();
511 }),
512 ),
513 Effect.repeat({ until: (idle) => idle }),
514 Effect.timeout("5 minutes"),
515 Effect.orDie,
516 Effect.asVoid,
517 Effect.provideService(Clock.Clock, wallClock),
518 );
519 const result = yield* runOrchestratorV2Scenario(scenario, { afterSteps: waitForGrokIdle }).pipe(
520 Effect.provide(
521 ProviderReplayHarness.layerWithRegistry(
522 scenario,
523 layerRegistry,
524 variant.runContinuationWorker === true ? { runContinuationWorker: true } : {},
525 ),
526 ),
527 provideDeterministicTestRuntime,
528 Effect.scoped,
529 );
530
531 const { entries, droppedFrames } = wireToEntries(tee.wire);
532 const closedCleanly = entries.some(
533 (entry) =>
534 entry.type === "expect_outbound" &&
535 isRecord(entry.frame) &&
536 entry.frame.method === "session/close",
537 );
538 const initialize = entries.find(
539 (entry) =>
540 entry.type === "emit_inbound" && isRecord(entry.frame) && entry.frame.method === "initialize",
541 );
542 const initializeMeta =
543 initialize?.type === "emit_inbound" &&
544 isRecord(initialize.frame) &&
545 isRecord(initialize.frame.result) &&
546 isRecord(initialize.frame.result._meta)
547 ? initialize.frame.result._meta
548 : {};
549 const transcript = {
550 provider: GROK_PROVIDER,
551 protocol: ACP_PROTOCOL,
552 version: "1",
553 scenario: fixtureName,
554 metadata: {
555 generatedBy: "live-grok-recorder",
556 grokVersion: initializeMeta.agentVersion ?? "unknown",
557 normalization:
558 "Session ids are fixed UUIDs, the workspace is <workspace>, HOME is /home/grok-replay and the recording user is grok-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are <any>. Personal skills, machine identity, account settings and announcement broadcasts are removed, as are responses to Grok-internal request ids that T3's protocol drops. Timestamps are kept as recorded.",
559 droppedFrames,
560 },
561 entries: [
562 ...normalizeEntries({
563 entries,
564 workspace: realWorkspace,
565 home: process.env.HOME ?? "",
566 user: process.env.USER ?? "",
567 runtimeInstructions: buildRuntimeInstructions({
568 harness: "Grok",
569 model: variant.modelSelection.model,
570 }),
571 }),
572 { type: "runtime_exit", status: closedCleanly ? "success" : "cancelled" } as const,
573 ],
574 };
575
576 const outputPath = readArgValues("--out")[0] ?? (yield* path.fromFileUrl(variant.transcriptFile));
577 const { entries: transcriptEntries, ...header } = transcript;
578 yield* fs.writeFileString(
579 outputPath,
580 [
581 encodeJson({ type: "transcript_start", ...header }),
582 ...transcriptEntries.map((entry) => encodeJson(entry)),
583 "",
584 ].join("\n"),
585 );
586 yield* Console.log(`Wrote ${transcriptEntries.length} Grok ACP replay entries to ${outputPath}`);
587
588 // The live orchestration must already satisfy the fixture's assertions;
589 // replay then proves the recorded frames reproduce it.
590 const liveFailure = yield* Effect.try(() => variant.assertOutput(result, transcript)).pipe(
591 Effect.flip,
592 Effect.option,
593 );
594 if (liveFailure._tag === "Some") {
595 yield* Console.log(`Live orchestration failed ${fixtureName} assertions:`, liveFailure.value);
596 }
597});
598
599const scenarios = readArgValues("--scenario").flatMap((value) => value.split(","));
600if (scenarios.length === 0) {
601 throw new Error("Pass --scenario <fixture name>[,<fixture name>...]");
602}
603
604await Effect.runPromise(
605 Effect.forEach(scenarios, (name) => Effect.scoped(recordScenario(name)), {
606 discard: true,
607 }).pipe(Effect.provide(NodeServices.layer)),
608);