mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-12 13:37:19 +02:00
606 lines
23 KiB
TypeScript
606 lines
23 KiB
TypeScript
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
import * as Console from "effect/Console";
|
|
import * as Effect from "effect/Effect";
|
|
import * as FileSystem from "effect/FileSystem";
|
|
import * as Path from "effect/Path";
|
|
import * as Schema from "effect/Schema";
|
|
|
|
import {
|
|
recordClaudeAgentSdkReplayTranscript,
|
|
CLAUDE_AGENT_SDK_REPLAY_PROTOCOL,
|
|
} from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts";
|
|
import { claudeRuntimeQueryPolicyForRuntimePolicy } from "../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts";
|
|
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
|
|
import type { RuntimePolicyV2Override } from "../src/orchestration-v2/RuntimePolicy.ts";
|
|
import { makeCheckpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
|
|
import { CLAUDE_MODEL_SELECTION } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
|
|
import {
|
|
MESSAGE_STEERING_INITIAL_PROMPT,
|
|
MULTI_TURN_FIRST_PROMPT,
|
|
MESSAGE_STEERING_STEER_PROMPT,
|
|
READ_ONLY_NEVER_POLICY,
|
|
READ_ONLY_ON_REQUEST_POLICY,
|
|
RESTRICTED_GRANULAR_POLICY,
|
|
MULTI_TURN_SECOND_PROMPT,
|
|
SIMPLE_PROMPT,
|
|
SUBAGENT_PROMPT,
|
|
THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
|
|
THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
|
|
THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
|
|
THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT,
|
|
THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT,
|
|
THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT,
|
|
THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT,
|
|
THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT,
|
|
THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT,
|
|
THREAD_FORK_NATIVE_SOURCE_PROMPT,
|
|
THREAD_FORK_NATIVE_TARGET_PROMPT,
|
|
THREAD_MERGE_BACK_FORK_PROMPT,
|
|
THREAD_MERGE_BACK_HANDOFF_PROMPT,
|
|
THREAD_MERGE_BACK_RECALL_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_RECALL_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT,
|
|
THREAD_MERGE_BACK_SOURCE_PROMPT,
|
|
THREAD_ROLLBACK_AFTER_PROMPT,
|
|
THREAD_ROLLBACK_FIRST_PROMPT,
|
|
THREAD_ROLLBACK_SECOND_PROMPT,
|
|
TOOL_CALL_READ_ONLY_PROMPT,
|
|
TOOL_CALL_READ_ONLY_WORKSPACE_ROOT,
|
|
TOOL_CALL_WRITE_PROMPT,
|
|
TURN_INTERRUPT_MID_TOOL_PROMPT,
|
|
TURN_INTERRUPT_PROMPT,
|
|
TURN_INTERRUPT_RECOVERY_PROMPT,
|
|
WORKSPACE_NEVER_POLICY,
|
|
WEB_SEARCH_PROMPT,
|
|
} from "../src/orchestration-v2/testkit/fixtures/shared.ts";
|
|
import { CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_subagent_after_root/input.ts";
|
|
import {
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_LAUNCH_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_STOP_PROMPT,
|
|
} from "../src/orchestration-v2/testkit/fixtures/claude_background_subagent_lifecycle/input.ts";
|
|
import { CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_monitor_wake/input.ts";
|
|
import { CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_background_subagent_wake/input.ts";
|
|
import { CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_nested_subagent_model/input.ts";
|
|
import { CLAUDE_MCP_TOOL_PRESENTATION_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_mcp_tool_presentation/input.ts";
|
|
import { CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_task_interrupt/input.ts";
|
|
import { CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT } from "../src/orchestration-v2/testkit/fixtures/claude_background_wake_before_queued_prompt/input.ts";
|
|
import {
|
|
CLAUDE_BACKGROUND_TASK_WAKE_FOLLOW_UP_PROMPT,
|
|
CLAUDE_BACKGROUND_TASK_WAKE_PROMPT,
|
|
} from "../src/orchestration-v2/testkit/fixtures/claude_background_task_wake/input.ts";
|
|
import {
|
|
DENIED_WRITE_POLICY,
|
|
TOOL_CALL_DENIED_WRITE_PROMPT,
|
|
TOOL_CALL_DENIED_WRITE_TARGET,
|
|
} from "../src/orchestration-v2/testkit/fixtures/tool_call_denied_write/input.ts";
|
|
import {
|
|
validateClaudeReplayRecordingSelection,
|
|
type ClaudeRecordingQueryMode,
|
|
} from "./claudeReplayRecordingConfig.ts";
|
|
|
|
const CLAUDE_RECORDINGS = {
|
|
simple: {
|
|
prompts: [SIMPLE_PROMPT],
|
|
defaultTranscriptFile: "fixtures/simple/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
multi_turn: {
|
|
prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
|
|
defaultTranscriptFile: "fixtures/multi_turn/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
multi_turn_restart: {
|
|
prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
|
|
defaultTranscriptFile: "fixtures/multi_turn_restart/claude_transcript.ndjson",
|
|
queryMode: "restart",
|
|
enableTools: true,
|
|
},
|
|
queued_turn: {
|
|
prompts: [MULTI_TURN_FIRST_PROMPT, MULTI_TURN_SECOND_PROMPT],
|
|
defaultTranscriptFile: "fixtures/queued_turn/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
message_steering: {
|
|
prompts: [MESSAGE_STEERING_INITIAL_PROMPT, MESSAGE_STEERING_STEER_PROMPT],
|
|
defaultTranscriptFile: "fixtures/message_steering/claude_transcript.ndjson",
|
|
queryMode: "active_steering",
|
|
enableTools: true,
|
|
},
|
|
turn_interrupt_mid_tool: {
|
|
prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT],
|
|
defaultTranscriptFile: "fixtures/turn_interrupt_mid_tool/claude_transcript.ndjson",
|
|
queryMode: "interrupt",
|
|
enableTools: true,
|
|
interruptAfter: "tool_use",
|
|
},
|
|
turn_interrupt: {
|
|
prompts: [TURN_INTERRUPT_PROMPT],
|
|
defaultTranscriptFile: "fixtures/turn_interrupt/claude_transcript.ndjson",
|
|
queryMode: "interrupt",
|
|
enableTools: true,
|
|
},
|
|
turn_interrupt_restart: {
|
|
prompts: [TURN_INTERRUPT_MID_TOOL_PROMPT, TURN_INTERRUPT_RECOVERY_PROMPT],
|
|
defaultTranscriptFile: "fixtures/turn_interrupt_restart/claude_transcript.ndjson",
|
|
queryMode: "interrupt_restart",
|
|
enableTools: true,
|
|
interruptAfter: "tool_use",
|
|
},
|
|
tool_call_read_only: {
|
|
prompts: [TOOL_CALL_READ_ONLY_PROMPT],
|
|
defaultTranscriptFile: "fixtures/tool_call_read_only/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
runtimePolicyOverride: READ_ONLY_NEVER_POLICY,
|
|
},
|
|
tool_call_read_only_on_request: {
|
|
prompts: [TOOL_CALL_WRITE_PROMPT],
|
|
defaultTranscriptFile: "fixtures/tool_call_read_only_on_request/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
runtimePolicyOverride: READ_ONLY_ON_REQUEST_POLICY,
|
|
},
|
|
tool_call_workspace_never: {
|
|
prompts: [TOOL_CALL_WRITE_PROMPT],
|
|
defaultTranscriptFile: "fixtures/tool_call_workspace_never/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
runtimePolicyOverride: WORKSPACE_NEVER_POLICY,
|
|
},
|
|
tool_call_denied_write: {
|
|
prompts: [TOOL_CALL_DENIED_WRITE_PROMPT],
|
|
defaultTranscriptFile: "fixtures/tool_call_denied_write/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
runtimePolicyOverride: DENIED_WRITE_POLICY,
|
|
permissionDecision: "decline",
|
|
expectedAbsentWorkspacePaths: [TOOL_CALL_DENIED_WRITE_TARGET],
|
|
},
|
|
tool_call_restricted_granular: {
|
|
prompts: [TOOL_CALL_WRITE_PROMPT],
|
|
defaultTranscriptFile: "fixtures/tool_call_restricted_granular/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
runtimePolicyOverride: RESTRICTED_GRANULAR_POLICY,
|
|
},
|
|
web_search: {
|
|
prompts: [WEB_SEARCH_PROMPT],
|
|
defaultTranscriptFile: "fixtures/web_search/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
claude_background_subagent_after_root: {
|
|
prompts: [CLAUDE_BACKGROUND_SUBAGENT_AFTER_ROOT_PROMPT],
|
|
defaultTranscriptFile:
|
|
"fixtures/claude_background_subagent_after_root/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
backgroundWakeCounts: [1],
|
|
},
|
|
claude_background_task_wake: {
|
|
prompts: [CLAUDE_BACKGROUND_TASK_WAKE_PROMPT, CLAUDE_BACKGROUND_TASK_WAKE_FOLLOW_UP_PROMPT],
|
|
defaultTranscriptFile: "fixtures/claude_background_task_wake/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
backgroundWakeCounts: [1, 0],
|
|
},
|
|
claude_background_monitor_wake: {
|
|
prompts: [CLAUDE_BACKGROUND_MONITOR_WAKE_PROMPT],
|
|
defaultTranscriptFile: "fixtures/claude_background_monitor_wake/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
backgroundWakeCounts: [1],
|
|
},
|
|
claude_background_subagent_lifecycle: {
|
|
prompts: [
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_LAUNCH_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_STOP_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/claude_background_subagent_lifecycle/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
backgroundWakeCounts: [1, 0, 1, 0],
|
|
},
|
|
// Each prompt is offered as soon as its turn and the wakes counted here
|
|
// settle, so a wake queued during a turn (Agent B's "stopped" notice) is
|
|
// still pending in the CLI when the next prompt arrives, and runs first.
|
|
claude_background_wake_before_queued_prompt: {
|
|
prompts: [
|
|
CLAUDE_BACKGROUND_WAKE_BEFORE_QUEUED_PROMPT_LAUNCH_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_STOP_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_RESUME_PROMPT,
|
|
CLAUDE_BACKGROUND_SUBAGENT_LIFECYCLE_FINAL_PROMPT,
|
|
],
|
|
defaultTranscriptFile:
|
|
"fixtures/claude_background_wake_before_queued_prompt/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
backgroundWakeCounts: [1, 0, 1, 0],
|
|
offerNextPromptImmediately: true,
|
|
},
|
|
claude_background_task_interrupt: {
|
|
prompts: [CLAUDE_BACKGROUND_TASK_INTERRUPT_PROMPT],
|
|
defaultTranscriptFile: "fixtures/claude_background_task_interrupt/claude_transcript.ndjson",
|
|
queryMode: "interrupt",
|
|
enableTools: true,
|
|
interruptAfter: "tool_use",
|
|
interruptAfterToolUses: 2,
|
|
},
|
|
claude_nested_background_subagent_wake: {
|
|
prompts: [CLAUDE_NESTED_BACKGROUND_SUBAGENT_WAKE_PROMPT],
|
|
defaultTranscriptFile:
|
|
"fixtures/claude_nested_background_subagent_wake/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
backgroundWakeCounts: [1],
|
|
},
|
|
claude_nested_subagent_model: {
|
|
prompts: [CLAUDE_NESTED_SUBAGENT_MODEL_PROMPT],
|
|
defaultTranscriptFile: "fixtures/claude_nested_subagent_model/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
// Needs the claude.ai Firecrawl connector on the recording account. Claude
|
|
// Code describes MCP tool uses in an undeclared `tool_use_meta` field.
|
|
claude_mcp_tool_presentation: {
|
|
prompts: [CLAUDE_MCP_TOOL_PRESENTATION_PROMPT],
|
|
defaultTranscriptFile: "fixtures/claude_mcp_tool_presentation/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
subagent: {
|
|
prompts: [SUBAGENT_PROMPT],
|
|
defaultTranscriptFile: "fixtures/subagent/claude_transcript.ndjson",
|
|
queryMode: "streaming",
|
|
enableTools: true,
|
|
},
|
|
thread_rollback: {
|
|
prompts: [
|
|
THREAD_ROLLBACK_FIRST_PROMPT,
|
|
THREAD_ROLLBACK_SECOND_PROMPT,
|
|
THREAD_ROLLBACK_AFTER_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/thread_rollback/claude_transcript.ndjson",
|
|
queryMode: "resume_at_cursor",
|
|
enableTools: true,
|
|
},
|
|
thread_fork_native: {
|
|
prompts: [THREAD_FORK_NATIVE_SOURCE_PROMPT, THREAD_FORK_NATIVE_TARGET_PROMPT],
|
|
defaultTranscriptFile: "fixtures/thread_fork_native/claude_transcript.ndjson",
|
|
queryMode: "fork_session",
|
|
enableTools: true,
|
|
},
|
|
thread_fork_native_prior_turn: {
|
|
prompts: [
|
|
THREAD_FORK_NATIVE_PRIOR_TURN_ALPHA_PROMPT,
|
|
THREAD_FORK_NATIVE_PRIOR_TURN_BETA_PROMPT,
|
|
THREAD_FORK_NATIVE_PRIOR_TURN_REPEAT_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/thread_fork_native_prior_turn/claude_transcript.ndjson",
|
|
queryMode: "fork_session_prior_turn",
|
|
enableTools: true,
|
|
},
|
|
thread_fork_native_continue: {
|
|
prompts: [
|
|
THREAD_FORK_NATIVE_CONTINUE_SOURCE_PROMPT,
|
|
THREAD_FORK_NATIVE_CONTINUE_FIRST_PROMPT,
|
|
THREAD_FORK_NATIVE_CONTINUE_SECOND_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/thread_fork_native_continue/claude_transcript.ndjson",
|
|
queryMode: "fork_session_continue",
|
|
enableTools: true,
|
|
},
|
|
thread_fork_native_siblings: {
|
|
prompts: [
|
|
THREAD_FORK_NATIVE_SIBLINGS_SOURCE_PROMPT,
|
|
THREAD_FORK_NATIVE_SIBLINGS_FIRST_PROMPT,
|
|
THREAD_FORK_NATIVE_SIBLINGS_SECOND_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/thread_fork_native_siblings/claude_transcript.ndjson",
|
|
queryMode: "fork_session_siblings",
|
|
enableTools: true,
|
|
},
|
|
thread_merge_back_continue: {
|
|
prompts: [
|
|
THREAD_MERGE_BACK_SOURCE_PROMPT,
|
|
THREAD_MERGE_BACK_FORK_PROMPT,
|
|
THREAD_MERGE_BACK_HANDOFF_PROMPT,
|
|
THREAD_MERGE_BACK_RECALL_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/thread_merge_back_continue/claude_transcript.ndjson",
|
|
queryMode: "fork_session_merge_back",
|
|
enableTools: true,
|
|
},
|
|
thread_merge_back_siblings: {
|
|
prompts: [
|
|
THREAD_MERGE_BACK_SIBLINGS_SOURCE_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_FIRST_FORK_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_SECOND_FORK_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_FIRST_HANDOFF_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_SECOND_HANDOFF_PROMPT,
|
|
THREAD_MERGE_BACK_SIBLINGS_RECALL_PROMPT,
|
|
],
|
|
defaultTranscriptFile: "fixtures/thread_merge_back_siblings/claude_transcript.ndjson",
|
|
queryMode: "fork_session_merge_back_siblings",
|
|
enableTools: true,
|
|
},
|
|
} as const;
|
|
|
|
function readArgValue(name: string): string | undefined {
|
|
const args = process.argv.slice(2);
|
|
const index = args.indexOf(name);
|
|
return index >= 0 ? args[index + 1] : undefined;
|
|
}
|
|
|
|
function selectedQueryMode(defaultMode: ClaudeRecordingQueryMode): ClaudeRecordingQueryMode {
|
|
const raw = readArgValue("--query-mode") ?? process.env.T3_CLAUDE_REPLAY_QUERY_MODE;
|
|
if (raw === undefined) {
|
|
return defaultMode;
|
|
}
|
|
if (
|
|
raw === "streaming" ||
|
|
raw === "restart" ||
|
|
raw === "resume_at_cursor" ||
|
|
raw === "fork_session" ||
|
|
raw === "fork_session_prior_turn" ||
|
|
raw === "fork_session_continue" ||
|
|
raw === "fork_session_siblings" ||
|
|
raw === "fork_session_merge_back" ||
|
|
raw === "fork_session_merge_back_siblings" ||
|
|
raw === "active_steering" ||
|
|
raw === "interrupt" ||
|
|
raw === "interrupt_restart"
|
|
) {
|
|
return raw;
|
|
}
|
|
throw new Error(
|
|
`Unsupported Claude replay query mode '${raw}'. Use 'streaming', 'restart', 'resume_at_cursor', 'fork_session', 'fork_session_prior_turn', 'fork_session_continue', 'fork_session_siblings', 'fork_session_merge_back', 'fork_session_merge_back_siblings', 'active_steering', 'interrupt', or 'interrupt_restart'.`,
|
|
);
|
|
}
|
|
|
|
const scenario = readArgValue("--scenario") ?? process.env.T3_CLAUDE_REPLAY_SCENARIO ?? "simple";
|
|
const recording = CLAUDE_RECORDINGS[scenario as keyof typeof CLAUDE_RECORDINGS];
|
|
const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
|
|
|
|
if (recording === undefined) {
|
|
throw new Error(`Claude replay fixture '${scenario}' is not configured.`);
|
|
}
|
|
|
|
const positionalOutputPath = process.argv[2]?.startsWith("--") ? undefined : process.argv[2];
|
|
const path = await Effect.runPromise(
|
|
Effect.service(Path.Path).pipe(Effect.provide(NodeServices.layer)),
|
|
);
|
|
const outputPath =
|
|
readArgValue("--out") ??
|
|
positionalOutputPath ??
|
|
(await Effect.runPromise(
|
|
path.fromFileUrl(
|
|
new URL(
|
|
`../src/orchestration-v2/testkit/${recording.defaultTranscriptFile}`,
|
|
import.meta.url,
|
|
),
|
|
),
|
|
));
|
|
|
|
function encodeTranscriptNdjson(
|
|
transcript: Awaited<ReturnType<typeof recordClaudeAgentSdkReplayTranscript>>,
|
|
): string {
|
|
const { entries, ...metadata } = transcript;
|
|
return [
|
|
JSON.stringify({ type: "transcript_start", ...metadata }),
|
|
...entries.map((entry) => JSON.stringify(entry)),
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function joinPath(directory: string, fileName: string): string {
|
|
return `${directory.replace(/\/+$/u, "")}/${fileName.replace(/^\/+/u, "")}`;
|
|
}
|
|
|
|
function runFileSystem<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>): Promise<A> {
|
|
return Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
|
|
}
|
|
|
|
function selectedPrompts(): ReadonlyArray<string> {
|
|
if (process.env.T3_CLAUDE_REPLAY_PROMPTS !== undefined) {
|
|
return process.env.T3_CLAUDE_REPLAY_PROMPTS.split("\n---\n").filter(
|
|
(prompt) => prompt.length > 0,
|
|
);
|
|
}
|
|
if (process.env.T3_CLAUDE_REPLAY_PROMPT !== undefined) {
|
|
return [process.env.T3_CLAUDE_REPLAY_PROMPT];
|
|
}
|
|
return recording.prompts;
|
|
}
|
|
|
|
const prompts = selectedPrompts();
|
|
const queryMode = selectedQueryMode(recording.queryMode);
|
|
validateClaudeReplayRecordingSelection({
|
|
scenario,
|
|
configuredQueryMode: recording.queryMode,
|
|
selectedQueryMode: queryMode,
|
|
configuredPromptCount: recording.prompts.length,
|
|
selectedPromptCount: prompts.length,
|
|
});
|
|
|
|
function runtimePolicyForRecording(input: {
|
|
readonly cwd: string;
|
|
readonly override?: RuntimePolicyV2Override;
|
|
}): ProviderAdapter.ProviderAdapterV2RuntimePolicy {
|
|
return ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
|
|
runtimeMode: "full-access",
|
|
interactionMode: "default",
|
|
cwd: input.override?.cwd ?? input.cwd,
|
|
...(input.override?.approvalPolicy === undefined
|
|
? {}
|
|
: { approvalPolicy: input.override.approvalPolicy }),
|
|
...(input.override?.sandboxPolicy === undefined
|
|
? {}
|
|
: { sandboxPolicy: input.override.sandboxPolicy }),
|
|
...(input.override?.reasoningEffort === undefined
|
|
? {}
|
|
: { reasoningEffort: input.override.reasoningEffort }),
|
|
});
|
|
}
|
|
|
|
async function makeToolCallReadOnlyRecordingWorkspace(): Promise<string> {
|
|
await runFileSystem(
|
|
Effect.gen(function* () {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
yield* fs.remove(TOOL_CALL_READ_ONLY_WORKSPACE_ROOT, { recursive: true, force: true });
|
|
yield* fs.makeDirectory(TOOL_CALL_READ_ONLY_WORKSPACE_ROOT, { recursive: true });
|
|
}),
|
|
);
|
|
return TOOL_CALL_READ_ONLY_WORKSPACE_ROOT;
|
|
}
|
|
|
|
const cwd =
|
|
process.env.T3_CLAUDE_REPLAY_CWD ??
|
|
(scenario === "tool_call_read_only"
|
|
? await makeToolCallReadOnlyRecordingWorkspace()
|
|
: await makeCheckpointWorkspace(`claude-agent-sdk-record-${scenario}`));
|
|
const shouldRemoveCwd = process.env.T3_CLAUDE_REPLAY_CWD === undefined;
|
|
|
|
const expectedAbsentWorkspacePaths =
|
|
"expectedAbsentWorkspacePaths" in recording ? recording.expectedAbsentWorkspacePaths : [];
|
|
|
|
// Scenarios that deny a tool call prove the denial by checking the real
|
|
// recording workspace, not just the transcript: the target must be absent
|
|
// before and after the turn, and the verified paths are preserved in the
|
|
// transcript metadata before the temporary workspace is removed.
|
|
async function assertWorkspacePathsAbsent(phase: "before" | "after"): Promise<void> {
|
|
const presentPaths = await runFileSystem(
|
|
Effect.gen(function* () {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
const present: Array<string> = [];
|
|
for (const relativePath of expectedAbsentWorkspacePaths) {
|
|
if (yield* fs.exists(joinPath(cwd, relativePath))) {
|
|
present.push(relativePath);
|
|
}
|
|
}
|
|
return present;
|
|
}),
|
|
);
|
|
if (presentPaths.length > 0) {
|
|
throw new Error(
|
|
`Claude replay fixture '${scenario}' expected ${presentPaths
|
|
.map((relativePath) => `'${relativePath}'`)
|
|
.join(
|
|
", ",
|
|
)} to be absent ${phase} the recorded turn, but found in the recording workspace.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (shouldRemoveCwd && (scenario === "tool_call_read_only" || scenario === "subagent")) {
|
|
await runFileSystem(
|
|
Effect.gen(function* () {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
yield* fs.writeFileString(
|
|
joinPath(cwd, "package.json"),
|
|
encodeUnknownJsonString({
|
|
name: "claude-read-only-fixture",
|
|
private: true,
|
|
scripts: { typecheck: "tsc --noEmit" },
|
|
}),
|
|
);
|
|
yield* fs.writeFileString(
|
|
joinPath(cwd, "tsconfig.json"),
|
|
encodeUnknownJsonString({
|
|
compilerOptions: {
|
|
module: "ESNext",
|
|
strict: true,
|
|
target: "ES2022",
|
|
},
|
|
}),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
try {
|
|
const runtimePolicy = runtimePolicyForRecording({
|
|
cwd,
|
|
...("runtimePolicyOverride" in recording ? { override: recording.runtimePolicyOverride } : {}),
|
|
});
|
|
const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(runtimePolicy);
|
|
|
|
await assertWorkspacePathsAbsent("before");
|
|
const transcript = await recordClaudeAgentSdkReplayTranscript({
|
|
scenario,
|
|
prompts,
|
|
modelSelection: {
|
|
...CLAUDE_MODEL_SELECTION,
|
|
model: process.env.T3_CLAUDE_REPLAY_MODEL ?? CLAUDE_MODEL_SELECTION.model,
|
|
},
|
|
cwd,
|
|
...(process.env.T3_CLAUDE_REPLAY_SESSION_ID === undefined
|
|
? {}
|
|
: { sessionId: process.env.T3_CLAUDE_REPLAY_SESSION_ID }),
|
|
queryMode,
|
|
...("enableTools" in recording && recording.enableTools === true ? { enableTools: true } : {}),
|
|
...(queryPolicy.tools === undefined ? {} : { tools: queryPolicy.tools }),
|
|
permissionMode: queryPolicy.permissionMode,
|
|
...(queryPolicy.allowedTools === undefined ? {} : { allowedTools: queryPolicy.allowedTools }),
|
|
...(queryPolicy.allowDangerouslySkipPermissions === undefined
|
|
? {}
|
|
: { allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions }),
|
|
...(queryPolicy.installPermissionCallback ? { enablePermissionCallback: true } : {}),
|
|
...("permissionDecision" in recording
|
|
? { permissionDecision: recording.permissionDecision }
|
|
: {}),
|
|
...("interruptAfter" in recording ? { interruptAfter: recording.interruptAfter } : {}),
|
|
...("interruptAfterToolUses" in recording
|
|
? { interruptAfterToolUses: recording.interruptAfterToolUses }
|
|
: {}),
|
|
...("backgroundWakeCounts" in recording
|
|
? { backgroundWakeCounts: recording.backgroundWakeCounts }
|
|
: {}),
|
|
...("offerNextPromptImmediately" in recording
|
|
? { offerNextPromptImmediately: recording.offerNextPromptImmediately }
|
|
: {}),
|
|
});
|
|
await assertWorkspacePathsAbsent("after");
|
|
const transcriptWithEvidence =
|
|
expectedAbsentWorkspacePaths.length === 0
|
|
? transcript
|
|
: {
|
|
...transcript,
|
|
metadata: {
|
|
...transcript.metadata,
|
|
verifiedAbsentWorkspacePaths: [...expectedAbsentWorkspacePaths],
|
|
},
|
|
};
|
|
await runFileSystem(
|
|
Effect.gen(function* () {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
|
|
yield* fs.writeFileString(outputPath, encodeTranscriptNdjson(transcriptWithEvidence));
|
|
}),
|
|
);
|
|
await Effect.runPromise(
|
|
Console.log(
|
|
`Wrote ${transcript.entries.length} ${CLAUDE_AGENT_SDK_REPLAY_PROTOCOL} replay entries to ${outputPath}`,
|
|
),
|
|
);
|
|
} finally {
|
|
if (shouldRemoveCwd) {
|
|
await runFileSystem(
|
|
Effect.gen(function* () {
|
|
const fs = yield* FileSystem.FileSystem;
|
|
yield* fs.remove(cwd, { recursive: true, force: true });
|
|
}),
|
|
);
|
|
}
|
|
}
|