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

record-kiro-acp-replay-fixture.ts

624 lines25.0 KB

1/**
2 * Records a Kiro ACP replay transcript through the real orchestrator and
3 * KiroAdapterV2, teeing only the protocol logger. Run from apps/server, signed in
4 * with `kiro-cli login`, with `kiro-cli` on PATH (or T3_KIRO_BIN):
5 * node scripts/record-kiro-acp-replay-fixture.ts --scenario simple
6 */
7import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import type { ProviderReplayEntry } from "@t3tools/contracts";
10import { KiroSettings } from "@t3tools/provider-kiro/settings";
11import * as HostProcess from "@t3tools/shared/HostProcess";
12import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
13import * as Clock from "effect/Clock";
14import * as Console from "effect/Console";
15import * as Config from "effect/Config";
16import * as Option from "effect/Option";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import type * as EffectAcpProtocol from "effect-acp/protocol";
23​
24import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
25import { KIRO_PROVIDER, makeKiroAdapterV2 } from "@t3tools/provider-kiro/testing";
26import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter";
27import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
28import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
29import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts";
30import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts";
31import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";
32import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
33import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts";
34import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
35import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
36import { makeKiroAcpRuntime } from "@t3tools/provider-kiro/testing";
37import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
38​
39const wallClock = Clock.Clock.defaultValue();
40const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
41const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
42​
43// Kiro broadcasts T3 never reads. They carry the account's governance flags,
44// the full text of Kiro's bundled steering documents, its powers, the user's
45// hooks, the session roster and memory sync state; the client ignores every
46// one of them (no handler is registered).
47const DROPPED_INBOUND_METHODS = new Set([
48 "_kiro/governance/state",
49 "_kiro/powers/items_changed",
50 "_kiro/steering/documents_changed",
51 "_kiro/progressive_context/items_changed",
52 "_kiro/sessions/changed",
53 "_kiro/tools/didChange",
54 "_kiro/hooks/didChange",
55 "_kiro/mcp/status",
56 "_kiro/memory/syncStatus",
57]);
58const HOME_PLACEHOLDER = "/home/kiro-replay";
59​
60interface JsonRpcMessage {
61 readonly id?: string | number | null;
62 readonly method?: string;
63 readonly params?: unknown;
64 readonly result?: unknown;
65 readonly error?: unknown;
67​
68type WireMessage =
69 | { readonly direction: "incoming" | "outgoing"; readonly message: JsonRpcMessage }
70 /** One ACP process ended and the adapter spawned the next (an idle release). */
71 | { readonly direction: "respawn" };
72​
73function readArgValues(name: string): ReadonlyArray<string> {
74 const args = process.argv.slice(2);
75 return args.flatMap((arg, index) => (arg === name && args[index + 1] ? [args[index + 1]!] : []));
77​
78function isRecord(value: unknown): value is Record<string, unknown> {
79 return typeof value === "object" && value !== null && !Array.isArray(value);
81​
82function mapStrings(value: unknown, map: (text: string) => string): unknown {
83 if (typeof value === "string") return map(value);
84 if (Array.isArray(value)) return value.map((entry) => mapStrings(entry, map));
85 if (!isRecord(value)) return value;
86 return Object.fromEntries(
87 Object.entries(value).map(([key, entry]) => [key, mapStrings(entry, map)]),
88 );
90​
91/**
92 * Tees raw ACP lines in wire order. A later process (the idle release the
93 * resume fixture forces) is marked as a respawn, which replay turns into a
94 * fresh replay agent that continues the transcript.
95 */
96function makeWireTee() {
97 const wire: Array<WireMessage> = [];
98 let runtimeCount = 0;
99 const attachRuntime = () => {
100 runtimeCount += 1;
101 if (runtimeCount > 1) wire.push({ direction: "respawn" });
102 let incomingBuffer = "";
103 const push = (direction: WireMessage["direction"], line: string) => {
104 if (line.trim().length === 0) return;
105 wire.push({ direction, message: decodeJson(line) as JsonRpcMessage });
106 };
107 return {
108 logIncoming: true,
109 logOutgoing: true,
110 logger: (event: EffectAcpProtocol.AcpProtocolLogEvent) =>
111 Effect.sync(() => {
112 if (event.stage !== "raw" || typeof event.payload !== "string") return;
113 if (event.direction === "outgoing") {
114 for (const line of event.payload.split("\n")) push("outgoing", line);
115 return;
116 }
117 incomingBuffer += event.payload;
118 const lines = incomingBuffer.split("\n");
119 incomingBuffer = lines.pop() ?? "";
120 for (const line of lines) push("incoming", line);
121 }),
122 };
123 };
124 // Kiro settles every turn through the session/prompt response, so it is
125 // done once each prompt T3 sent has been answered.
126 const kiroHasPendingWork = () => {
127 const pending = new Set<string>();
128 for (const wireMessage of wire) {
129 if (wireMessage.direction === "respawn") {
130 pending.clear();
131 continue;
132 }
133 const { direction, message } = wireMessage;
134 if (direction === "outgoing" && message.method === "session/prompt") {
135 pending.add(String(message.id));
136 } else if (direction === "incoming" && message.method === undefined) {
137 pending.delete(String(message.id));
138 }
139 }
140 return pending.size > 0;
141 };
142 return { wire, attachRuntime, kiroHasPendingWork };
144​
145function frameLabel(kind: string, method: string, params: unknown): string {
146 const update = isRecord(params) && isRecord(params.update) ? params.update : undefined;
147 const updateType = typeof update?.sessionUpdate === "string" ? `:${update.sessionUpdate}` : "";
148 return `${kind}:${method}${updateType}`;
150​
151/** Pairs JSON-RPC ids with their methods and emits the logical frames acp-replay-agent reads. */
152function wireToEntries(wire: ReadonlyArray<WireMessage>): {
153 readonly entries: Array<ProviderReplayEntry>;
154 readonly droppedFrames: number;
155} {
156 const entries: Array<ProviderReplayEntry> = [];
157 const t3Requests = new Map<string, string>();
158 const agentRequests = new Map<string, string>();
159 let droppedFrames = 0;
160 for (const wireMessage of wire) {
161 if (wireMessage.direction === "respawn") {
162 // JSON-RPC ids restart with the process.
163 t3Requests.clear();
164 agentRequests.clear();
165 entries.push({ type: "runtime_exit", status: "success" });
166 continue;
167 }
168 const { direction, message } = wireMessage;
169 const type = direction === "outgoing" ? "expect_outbound" : "emit_inbound";
170 if (typeof message.method === "string") {
171 if (direction === "incoming" && DROPPED_INBOUND_METHODS.has(message.method)) {
172 droppedFrames += 1;
173 continue;
174 }
175 const isRequest = message.id !== undefined && message.id !== null;
176 if (isRequest) {
177 (direction === "outgoing" ? t3Requests : agentRequests).set(
178 String(message.id),
179 message.method,
180 );
181 }
182 const kind = isRequest ? "request" : "notification";
183 entries.push({
184 type,
185 label: frameLabel(kind, message.method, message.params),
186 frame: {
187 kind,
188 method: message.method,
189 ...(message.params === undefined ? {} : { params: message.params }),
190 },
191 });
192 continue;
193 }
194 const pending = direction === "outgoing" ? agentRequests : t3Requests;
195 const method = pending.get(String(message.id));
196 if (method === undefined) {
197 // A response to a request T3 never saw; T3's protocol drops it, so replay
198 // never sees it either.
199 droppedFrames += 1;
200 continue;
201 }
202 pending.delete(String(message.id));
203 entries.push({
204 type,
205 label: `response:${method}`,
206 frame: {
207 kind: "response",
208 method,
209 ...(message.result === undefined ? {} : { result: message.result }),
210 ...(message.error === undefined ? {} : { error: message.error }),
211 },
212 });
213 }
214 return { entries, droppedFrames };
216​
217const T3_INSTRUCTIONS_BODY = /<t3_code_instructions>\n[\s\S]*?\n<\/t3_code_instructions>/u;
218// A turn on another model sends its own runtime info, which the filter below
219// keeps; its model stays pinned and the linking guidance does not.
220const PULL_REQUEST_LINKING_BODY = /<pull_request_linking>\n[\s\S]*?\n<\/pull_request_linking>/u;
221​
222/** Replaces T3-owned request content so prompt wording changes do not invalidate recordings. */
223function normalizeOutboundFrame(frame: Record<string, unknown>, runtimeInstructions: string) {
224 const params = isRecord(frame.params) ? frame.params : undefined;
225 if (params === undefined) return frame;
226 switch (frame.method) {
227 case "initialize":
228 // Pin what T3 advertises (fs and terminal capabilities decide whether
229 // Kiro routes file and shell work through T3); the rest is <any>.
230 return {
231 ...frame,
232 params: Object.fromEntries(
233 Object.keys(params).map((key) => [
234 key,
235 key === "protocolVersion" || key === "clientCapabilities" || key === "_meta"
236 ? params[key]
237 : "<any>",
238 ]),
239 ),
240 };
241 case "session/new":
242 case "session/load":
243 case "session/resume":
244 return { ...frame, params: { ...params, mcpServers: "<any>" } };
245 case "session/prompt": {
246 if (!Array.isArray(params.prompt)) return frame;
247 const prompt = params.prompt.filter(
248 (part) => !(isRecord(part) && part.type === "text" && part.text === runtimeInstructions),
249 );
250 return {
251 ...frame,
252 params: {
253 ...params,
254 prompt: prompt.map((part) =>
255 isRecord(part) && typeof part.text === "string"
256 ? {
257 ...part,
258 text: part.text
259 .replace(
260 T3_INSTRUCTIONS_BODY,
261 "<t3_code_instructions>\n<any>\n</t3_code_instructions>",
262 )
263 .replace(
264 PULL_REQUEST_LINKING_BODY,
265 "<pull_request_linking>\n<any>\n</pull_request_linking>",
266 ),
267 }
268 : part,
269 ),
270 },
271 };
272 }
273 default:
274 return frame;
275 }
277​
278/**
279 * Drops the account-specific parts of Kiro's frames: user-scoped commands and
280 * agents, and the log directory `initialize` names.
281 */
282function normalizeInboundFrame(frame: Record<string, unknown>): Record<string, unknown> {
283 const params = isRecord(frame.params) ? frame.params : undefined;
284 const update = isRecord(params?.update) ? params.update : undefined;
285 if (
286 update?.sessionUpdate === "available_commands_update" &&
287 Array.isArray(update.availableCommands)
288 ) {
289 return {
290 ...frame,
291 params: {
292 ...params,
293 update: {
294 ...update,
295 availableCommands: update.availableCommands.filter((command) => {
296 const kiro =
297 isRecord(command) && isRecord(command._meta) && isRecord(command._meta.kiro)
298 ? command._meta.kiro
299 : undefined;
300 const resource = isRecord(kiro?.resource) ? kiro.resource : undefined;
301 const source = isRecord(resource?.source) ? resource.source : undefined;
302 return source === undefined || source.origin === "bundled";
303 }),
304 },
305 },
306 };
307 }
308 const result = isRecord(frame.result) ? frame.result : undefined;
309 const capabilities = isRecord(result?.agentCapabilities) ? result.agentCapabilities : undefined;
310 const meta = isRecord(capabilities?._meta) ? capabilities._meta : undefined;
311 const kiro = isRecord(meta?.kiro) ? meta.kiro : undefined;
312 if (frame.method === "initialize" && kiro !== undefined && "logging" in kiro) {
313 const { logging: _logging, ...kiroWithoutLogging } = kiro;
314 return {
315 ...frame,
316 result: {
317 ...result,
318 agentCapabilities: { ...capabilities, _meta: { ...meta, kiro: kiroWithoutLogging } },
319 },
320 };
321 }
322 return frame;
324​
325const OTHER_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/giu;
326​
327/** Collects Kiro session ids in first-seen order. */
328function collectSessionIds(entries: ReadonlyArray<ProviderReplayEntry>): ReadonlyArray<string> {
329 const ids: Array<string> = [];
330 const add = (value: unknown) => {
331 if (typeof value === "string" && value.length > 0 && !ids.includes(value)) ids.push(value);
332 };
333 for (const entry of entries) {
334 if (entry.type === "runtime_exit" || !isRecord(entry.frame)) continue;
335 const frame = entry.frame;
336 if (frame.kind === "response" && frame.method === "session/new" && isRecord(frame.result)) {
337 add(frame.result.sessionId);
338 }
339 if (entry.type === "emit_inbound" && isRecord(frame.params)) add(frame.params.sessionId);
340 }
341 return ids;
343​
344function normalizeEntries(input: {
345 readonly entries: ReadonlyArray<ProviderReplayEntry>;
346 readonly workspace: string;
347 readonly home: string;
348 readonly user: string;
349 readonly runtimeInstructions: string;
350}): Array<ProviderReplayEntry> {
351 const sessionIds = collectSessionIds(input.entries);
352 const replacements: Array<readonly [string, string]> = [
353 ...sessionIds.map(
354 (id, index) =>
355 [id, `sess_00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`] as const,
356 ),
357 // Kiro's snapshot URIs carry paths URL-encoded once or twice
358 // (`?originalPath%3D%252Fhome%252F…`); replace those forms first.
359 // A HOME of "/" (root) or "" would replace every slash, method names included.
360 ...[input.workspace, input.home].flatMap((path) => {
361 if (path.length <= 1) return [];
362 const once = encodeURIComponent(path);
363 const twice = encodeURIComponent(once);
364 const placeholder = path === input.workspace ? "<workspace>" : HOME_PLACEHOLDER;
365 return [
366 [twice, encodeURIComponent(encodeURIComponent(placeholder))] as const,
367 [once, encodeURIComponent(placeholder)] as const,
368 ];
369 }),
370 ...(input.workspace.length <= 1 ? [] : [[input.workspace, "<workspace>"] as const]),
371 ...(input.home.length <= 1 ? [] : [[input.home, HOME_PLACEHOLDER] as const]),
372 ];
373 // Kiro's message and backend request ids, numbered in first-seen order.
374 const otherIds: Array<string> = [];
375 const numberOtherIds = (text: string) =>
376 text.replace(OTHER_UUID, (id) => {
377 if (id.startsWith("00000000-0000-4000-8000-")) return id;
378 if (!otherIds.includes(id)) otherIds.push(id);
379 return `00000000-0000-4000-9000-${String(otherIds.indexOf(id) + 1).padStart(12, "0")}`;
380 });
381 // Shell output (e.g. `ls -l`) names the recording user.
382 const user = /^[a-z_][a-z0-9_-]*$/iu.test(input.user) ? input.user : "";
383 const userPattern = user.length === 0 ? undefined : new RegExp(`\\b${user}\\b`, "gu");
384 const replaceAll = (text: string) => {
385 const replaced = replacements.reduce(
386 (current, [from, to]) => (from.length === 0 ? current : current.replaceAll(from, to)),
387 text,
388 );
389 const named =
390 userPattern === undefined ? replaced : replaced.replace(userPattern, "kiro-replay");
391 return numberOtherIds(named);
392 };
393 return input.entries.map((entry) => {
394 if (entry.type === "runtime_exit" || !isRecord(entry.frame)) return entry;
395 const frame =
396 entry.type === "expect_outbound"
397 ? normalizeOutboundFrame(entry.frame, input.runtimeInstructions)
398 : normalizeInboundFrame(entry.frame);
399 return {
400 ...entry,
401 ...(entry.label === undefined ? {} : { label: replaceAll(entry.label) }),
402 frame: mapStrings(frame, replaceAll),
403 };
404 });
406​
407const KIRO_RECORDING_SETTINGS = Schema.decodeSync(KiroSettings)({
408 enabled: true,
409 binaryPath: process.env.T3_KIRO_BIN ?? "kiro-cli",
410});
411​
412const RecorderEnv = Config.all({
413 T3_KIRO_VERSION: Config.String("T3_KIRO_VERSION").pipe(
414 Config.option,
415 Config.map(Option.getOrUndefined),
416 ),
417 HOME: Config.String("HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
418 USER: Config.String("USER").pipe(Config.option, Config.map(Option.getOrUndefined)),
419});
420​
421const recordScenario = Effect.fn("recordKiroScenario")(function* (fixtureName: string) {
422 const recorderEnv = yield* RecorderEnv;
423 const fixture = ORCHESTRATOR_REPLAY_FIXTURES.find((candidate) => candidate.name === fixtureName);
424 const variant = fixture?.providers.find((provider) => provider.driver === KIRO_PROVIDER);
425 if (fixture === undefined || variant === undefined) {
426 return yield* Effect.die(new Error(`No Kiro replay fixture named '${fixtureName}'.`));
427 }
428 const fs = yield* FileSystem.FileSystem;
429 const path = yield* Path.Path;
430 const fixtureInput = fixture.buildInput();
431 // Same README and seeded files as the replay workspace, so client-mediated
432 // fs reads answer identically at replay time.
433 const workspace = yield* checkpointWorkspace(fixtureName, fixtureInput.workspaceFiles);
434 const realWorkspace = yield* fs.realPath(workspace);
435 const materialized = yield* materializeFixtureInput({
436 scenario: fixtureName,
437 fixtureInput,
438 driver: KIRO_PROVIDER,
439 modelSelection: variant.modelSelection,
440 }).pipe(Effect.provide(IdAllocator.layer), provideDeterministicTestRuntime);
441 const scenario = {
442 name: `${fixtureName}/kiro-record`,
443 commands: materialized.commands,
444 // Gated fixtures pace replay with gates on the test clock. Live, Kiro and
445 // wall time pace themselves, so a fixture that gates records its
446 // dispatches only and then waits for Kiro to idle.
447 steps: materialized.steps.some(
448 (step) =>
449 step.type === "release_replay_gate" || step.type === "release_replay_gate_after_waiting",
450 )
451 ? materialized.steps.filter((step) => step.type === "dispatch")
452 : materialized.steps.map((step) =>
453 step.type === "finish_held_run" ? { ...step, type: "await_run_status" as const } : step,
454 ),
455 projectionThreadIds: materialized.projectionThreadIds,
456 runtimePolicyOverride: { ...variant.runtimePolicyOverride, cwd: realWorkspace },
457 };
458​
459 const tee = makeWireTee();
460 const layerRegistry = ProviderAdapterRegistry.layerFromAdaptersEffect(
461 Effect.gen(function* () {
462 const environment = yield* HostProcess.Environment;
463 const adapter = yield* makeKiroAdapterV2({
464 instanceId: variant.modelSelection.instanceId,
465 settings: KIRO_RECORDING_SETTINGS,
466 environment,
467 selfInvocation: yield* resolveSelfInvocation(),
468 // Production's runtime factory, with the protocol logger teeing raw lines.
469 makeRuntime: ({ runtimePolicy: _runtimePolicy, processEnvironment, ...input }) =>
470 makeKiroAcpRuntime({
471 ...input,
472 protocolLogging: tee.attachRuntime(),
473 settings: KIRO_RECORDING_SETTINGS,
474 environment: { ...environment, ...processEnvironment },
475 }),
476 });
477 // The scenario runs on the replay TestClock so its clock steps order
478 // dispatches as replay will. Kiro itself runs on wall time, so the
479 // adapter's session does too.
480 const onWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
481 Effect.provideService(effect, Clock.Clock, wallClock);
482 return [
483 {
484 ...adapter,
485 openSession: (input) =>
486 adapter.openSession(input).pipe(
487 Effect.map((session): ProviderAdapter.ProviderAdapterV2SessionRuntime => ({
488 ...session,
489 startTurn: (turnInput) => onWallClock(session.startTurn(turnInput)),
490 steerTurn: (turnInput) => onWallClock(session.steerTurn(turnInput)),
491 interruptTurn: (turnInput) => onWallClock(session.interruptTurn(turnInput)),
492 respondToRuntimeRequest: (turnInput) =>
493 onWallClock(session.respondToRuntimeRequest(turnInput)),
494 })),
495 onWallClock,
496 ),
497 },
498 ];
499 }),
500 ).pipe(
501 Layer.provide(
502 Layer.mergeAll(
503 TestProviderHost.layer().pipe(
504 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
505 ),
506 NodeServices.layer,
507 NodeCrypto.layer,
508 IdAllocator.layer,
509 ),
510 ),
511 );
512​
513 // Same deterministic runtime as replay (TestClock, seeded ids), so the
514 // scenario's clock steps order dispatches exactly as they will on replay.
515 // The session stays open until every prompt is answered and Kiro has been
516 // quiet for a few seconds, so the transcript holds everything replay emits.
517 let quietPolls = 0;
518 let seenFrames = -1;
519 const waitForKiroIdle = Effect.sleep("1 second").pipe(
520 Effect.andThen(
521 Effect.sync(() => {
522 quietPolls = seenFrames === tee.wire.length ? quietPolls + 1 : 0;
523 seenFrames = tee.wire.length;
524 return quietPolls >= 3 && !tee.kiroHasPendingWork();
525 }),
526 ),
527 Effect.repeat({ until: (idle) => idle }),
528 Effect.timeout("5 minutes"),
529 Effect.orDie,
530 Effect.asVoid,
531 Effect.provideService(Clock.Clock, wallClock),
532 );
533 const result = yield* runOrchestratorV2Scenario(scenario, { afterSteps: waitForKiroIdle }).pipe(
534 Effect.provide(
535 ProviderReplayHarness.layerWithRegistry(
536 scenario,
537 layerRegistry,
538 variant.runContinuationWorker === true ? { runContinuationWorker: true } : {},
539 ),
540 ),
541 provideDeterministicTestRuntime,
542 Effect.scoped,
543 );
544​
545 const { entries, droppedFrames } = wireToEntries(tee.wire);
546 const closedCleanly = entries.some(
547 (entry) =>
548 entry.type === "expect_outbound" &&
549 isRecord(entry.frame) &&
550 entry.frame.method === "session/close",
551 );
552 const transcript = {
553 provider: KIRO_PROVIDER,
554 protocol: ACP_PROTOCOL,
555 version: "1",
556 scenario: fixtureName,
557 metadata: {
558 generatedBy: "live-kiro-recorder",
559 kiroVersion: recorderEnv.T3_KIRO_VERSION ?? "unknown",
560 normalization:
561 "Session ids are fixed sess_ UUIDs and other UUIDs (message and request ids) are numbered in first-seen order, the workspace is <workspace>, HOME is /home/kiro-replay and the recording user is kiro-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are <any>. Kiro's log directory, user-scoped commands and agents, and the _kiro/* broadcasts T3 ignores (governance, steering documents, powers, tools, hooks, MCP status, session roster, memory sync) are removed. Timestamps are kept as recorded.",
562 droppedFrames,
563 },
564 entries: [
565 ...normalizeEntries({
566 entries,
567 workspace: realWorkspace,
568 home: recorderEnv.HOME ?? "",
569 user: recorderEnv.USER ?? "",
570 runtimeInstructions: buildRuntimeInstructions({
571 harness: "Kiro",
572 model: variant.modelSelection.model,
573 }),
574 }),
575 { type: "runtime_exit", status: closedCleanly ? "success" : "cancelled" } as const,
576 ],
577 };
578​
579 // The live orchestration must already satisfy the fixture's assertions
580 // before anything is written, so a changed Kiro response can never replace
581 // a good fixture with one that fails replay. `--force` writes it anyway to
582 // inspect the frames.
583 const liveFailure = (() => {
584 try {
585 variant.assertOutput(result, transcript);
586 return undefined;
587 } catch (cause) {
588 return cause;
589 }
590 })();
591 if (liveFailure !== undefined && !process.argv.includes("--force")) {
592 return yield* Effect.die(
593 new Error(`Live orchestration failed ${fixtureName} assertions; nothing was written.`, {
594 cause: liveFailure,
595 }),
596 );
597 }
598​
599 const outputPath = readArgValues("--out")[0] ?? (yield* path.fromFileUrl(variant.transcriptFile));
600 const { entries: transcriptEntries, ...header } = transcript;
601 yield* fs.writeFileString(
602 outputPath,
603 [
604 encodeJson({ type: "transcript_start", ...header }),
605 ...transcriptEntries.map((entry) => encodeJson(entry)),
606 "",
607 ].join("\n"),
608 );
609 yield* Console.log(`Wrote ${transcriptEntries.length} Kiro ACP replay entries to ${outputPath}`);
610});
611​
612const scenarios = readArgValues("--scenario").flatMap((value) => value.split(","));
613if (scenarios.length === 0) {
614 throw new Error("Pass --scenario <fixture name>[,<fixture name>...]");
616if (scenarios.length > 1 && readArgValues("--out").length > 0) {
617 throw new Error("--out cannot be used with multiple scenarios; run them separately.");
619​
620await Effect.runPromise(
621 Effect.forEach(scenarios, (name) => Effect.scoped(recordScenario(name)), {
622 discard: true,
623 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
624);