| 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 | */ |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | import { type ProviderReplayEntry } from "@t3tools/contracts"; |
| 14 | import { GrokSettings } from "@t3tools/provider-grok/settings"; |
| 15 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 16 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 17 | import * as Clock from "effect/Clock"; |
| 18 | import * as Console from "effect/Console"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Layer from "effect/Layer"; |
| 22 | import * as Path from "effect/Path"; |
| 23 | import * as Schema from "effect/Schema"; |
| 24 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 25 | |
| 26 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 27 | import { |
| 28 | GROK_DEFAULT_INSTANCE_ID, |
| 29 | GROK_PROVIDER, |
| 30 | grokLaunchRuntimeMode, |
| 31 | makeGrokAdapterV2, |
| 32 | } from "@t3tools/provider-grok/testing"; |
| 33 | import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter"; |
| 34 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 35 | import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 36 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 37 | import * as ProviderAdapterRegistry from "../src/orchestration-v2/ProviderAdapterRegistry.ts"; |
| 38 | import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; |
| 39 | import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; |
| 40 | import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; |
| 41 | import { runOrchestratorV2Scenario } from "../src/orchestration-v2/testkit/OrchestratorScenario.ts"; |
| 42 | import * as ProviderReplayHarness from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; |
| 43 | import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; |
| 44 | import { makeGrokAcpRuntime } from "@t3tools/provider-grok/testing"; |
| 45 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; |
| 46 | |
| 47 | const wallClock = Clock.Clock.defaultValue(); |
| 48 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 49 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 50 | |
| 51 | // Broadcasts T3 never reads: account tier, marketing, and client UI settings. |
| 52 | const DROPPED_INBOUND_METHODS = new Set(["_x.ai/settings/update", "_x.ai/announcements/update"]); |
| 53 | const HOME_PLACEHOLDER = "/home/grok-replay"; |
| 54 | |
| 55 | interface 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 | |
| 63 | interface WireMessage { |
| 64 | readonly direction: "incoming" | "outgoing"; |
| 65 | readonly message: JsonRpcMessage; |
| 66 | } |
| 67 | |
| 68 | function 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 | |
| 73 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 74 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 75 | } |
| 76 | |
| 77 | function 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. */ |
| 87 | function 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 | |
| 153 | function 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. */ |
| 169 | function 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 | |
| 226 | const 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. */ |
| 229 | function 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. */ |
| 281 | function 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 | |
| 333 | const 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. */ |
| 336 | function 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 | |
| 359 | function 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 | |
| 401 | const DEFAULT_GROK_SETTINGS = Schema.decodeUnknownSync(GrokSettings)({}); |
| 402 | |
| 403 | const 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 | |
| 599 | const scenarios = readArgValues("--scenario").flatMap((value) => value.split(",")); |
| 600 | if (scenarios.length === 0) { |
| 601 | throw new Error("Pass --scenario <fixture name>[,<fixture name>...]"); |
| 602 | } |
| 603 | |
| 604 | await Effect.runPromise( |
| 605 | Effect.forEach(scenarios, (name) => Effect.scoped(recordScenario(name)), { |
| 606 | discard: true, |
| 607 | }).pipe(Effect.provide(NodeServices.layer)), |
| 608 | ); |