/** * Tests for AgentRuntime * * Tests each phase of the runtime pipeline independently: * - ResolutionPhase: Local vs remote source handling * - ContextPhase: Frontmatter parsing, import expansion, command resolution * - TemplatePhase: Variable substitution and arg building * - ExecutionPhase: Command execution (mocked) */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; import { AgentRuntime, createRuntime, type ExecutionPlan, } from "./runtime"; import { clearConfigCache } from "./config"; describe("AgentRuntime", () => { let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "runtime-test-")); clearConfigCache(); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); describe("Resolution Phase", () => { it("resolves local file path", async () => { const filePath = join(tempDir, "test.claude.md"); await writeFile(filePath, "---\nmodel: opus\n---\nHello"); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); expect(resolved.type).toBe("local"); expect(resolved.path).toBe(filePath); expect(resolved.originalSource).toBe(filePath); expect(resolved.content).toContain("Hello"); expect(resolved.directory).toBe(tempDir); }); it("throws error for non-existent file", async () => { const runtime = createRuntime(); const filePath = join(tempDir, "nonexistent.md"); await expect(runtime.resolve(filePath)).rejects.toThrow("File not found"); }); it("resolves file content correctly", async () => { const content = `--- model: sonnet temperature: 0.7 --- This is the body content. Multiple lines.`; const filePath = join(tempDir, "content.claude.md"); await writeFile(filePath, content); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); expect(resolved.content).toBe(content); }); }); describe("Context Phase", () => { it("parses frontmatter and resolves command from filename", async () => { const filePath = join(tempDir, "test.claude.md"); await writeFile(filePath, `--- model: opus --- Hello World`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); expect(context.command).toBe("claude"); expect(context.frontmatter.model).toBe("opus"); expect(context.rawBody).toBe("Hello World"); expect(context.expandedBody).toBe("Hello World"); }); it("uses command from options over filename", async () => { const filePath = join(tempDir, "test.claude.md"); await writeFile(filePath, `---\n---\nHello`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved, { command: "gemini" }); expect(context.command).toBe("gemini"); }); it("expands file imports", async () => { const includedFile = join(tempDir, "included.txt"); await writeFile(includedFile, "Included content"); const mainFile = join(tempDir, "main.claude.md"); await writeFile(mainFile, `---\n---\n@./included.txt`); const runtime = createRuntime(); const resolved = await runtime.resolve(mainFile); const context = await runtime.buildContext(resolved); expect(context.expandedBody).toContain("Included content"); }); it("extracts environment variables from frontmatter", async () => { const filePath = join(tempDir, "env.claude.md"); await writeFile(filePath, `--- env: API_KEY: secret123 DEBUG: "true" --- Hello`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); expect(context.envVars).toEqual({ API_KEY: "secret123", DEBUG: "true", }); }); it("handles empty frontmatter", async () => { const filePath = join(tempDir, "empty.claude.md"); await writeFile(filePath, `---\n---\nJust body`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); // Built-in defaults for claude add print: true (print mode by default) expect(context.frontmatter).toEqual({ print: true }); expect(context.rawBody).toBe("Just body"); }); }); describe("Template Phase", () => { it("substitutes template variables", async () => { const filePath = join(tempDir, "template.claude.md"); await writeFile(filePath, `--- args: - name --- Hello {{ name }}!`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context, { passthroughArgs: ["World"], }); expect(processed.body).toBe("Hello World!"); expect(processed.templateVars).toEqual({ name: "World" }); }); it("builds CLI args from frontmatter", async () => { const filePath = join(tempDir, "args.claude.md"); await writeFile(filePath, `--- model: opus temperature: 0.5 verbose: true --- Body`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context); expect(processed.args).toContain("--model"); expect(processed.args).toContain("opus"); expect(processed.args).toContain("--temperature"); expect(processed.args).toContain("0.5"); expect(processed.args).toContain("--verbose"); }); it("extracts positional mappings", async () => { const filePath = join(tempDir, "positional.claude.md"); await writeFile(filePath, `--- $1: prompt --- Body`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context); expect(processed.positionalMappings.get(1)).toBe("prompt"); }); it("handles $varname fields with CLI flags", async () => { const filePath = join(tempDir, "varname.claude.md"); await writeFile(filePath, `--- $feature_name: default_feature --- Implement {{ feature_name }}`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context, { passthroughArgs: ["--feature_name", "custom_feature"], }); expect(processed.body).toBe("Implement custom_feature"); expect(processed.templateVars).toEqual({ feature_name: "custom_feature" }); }); it("uses default value when CLI flag not provided", async () => { const filePath = join(tempDir, "default.claude.md"); await writeFile(filePath, `--- $mode: development --- Mode: {{ mode }}`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context); expect(processed.body).toBe("Mode: development"); }); it("throws error for missing required variables in non-interactive mode", async () => { const filePath = join(tempDir, "missing.claude.md"); await writeFile(filePath, `---\n---\nHello {{ name }}!`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); await expect(runtime.processTemplate(context)).rejects.toThrow("Missing template variables: name"); }); it("prompts for missing variables when promptForMissing is provided", async () => { const filePath = join(tempDir, "prompt.claude.md"); await writeFile(filePath, `---\n---\nHello {{ name }}!`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context, { promptForMissing: async () => "PromptedName", }); expect(processed.body).toBe("Hello PromptedName!"); }); it("passes through remaining args to command", async () => { const filePath = join(tempDir, "passthrough.claude.md"); await writeFile(filePath, `--- args: - name --- Hello {{ name }}`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context, { passthroughArgs: ["World", "--extra", "flag"], }); expect(processed.args).toContain("--extra"); expect(processed.args).toContain("flag"); }); }); describe("Full Pipeline", () => { it("runs complete pipeline with dry run", async () => { const filePath = join(tempDir, "pipeline.claude.md"); await writeFile(filePath, `--- model: haiku --- Test prompt`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true }); expect(result.exitCode).toBe(0); expect(result.dryRun).toBe(true); expect(result.logPath).toBeTruthy(); }); it("includes stdin content in final body", async () => { const filePath = join(tempDir, "stdin.claude.md"); await writeFile(filePath, `---\n---\nBody content`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); const context = await runtime.buildContext(resolved); const processed = await runtime.processTemplate(context); // The execute phase adds stdin const stdinContent = "stdin data"; const finalBody = `\n${stdinContent}\n\n\n${processed.body}`; expect(finalBody).toContain(""); expect(finalBody).toContain("stdin data"); expect(finalBody).toContain("Body content"); }); it("handles cleanup correctly", async () => { const filePath = join(tempDir, "cleanup.claude.md"); await writeFile(filePath, `---\n---\nTest`); const runtime = createRuntime(); await runtime.resolve(filePath); // Cleanup should not throw for local files await expect(runtime.cleanup()).resolves.toBeUndefined(); }); }); describe("createRuntime factory", () => { it("creates new AgentRuntime instance", () => { const runtime = createRuntime(); expect(runtime).toBeInstanceOf(AgentRuntime); }); it("creates independent instances", () => { const runtime1 = createRuntime(); const runtime2 = createRuntime(); expect(runtime1).not.toBe(runtime2); }); }); describe("Error Handling", () => { it("throws descriptive error for command resolution failure", async () => { const filePath = join(tempDir, "nocommand.md"); await writeFile(filePath, `---\n---\nBody`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); await expect(runtime.buildContext(resolved)).rejects.toThrow("No command specified"); }); it("throws error for circular imports", async () => { const fileA = join(tempDir, "a.claude.md"); const fileB = join(tempDir, "b.md"); await writeFile(fileA, `---\n---\n@./b.md`); await writeFile(fileB, `@./a.claude.md`); const runtime = createRuntime(); const resolved = await runtime.resolve(fileA); await expect(runtime.buildContext(resolved)).rejects.toThrow("Circular import"); }); it("throws error for import of non-existent file", async () => { const filePath = join(tempDir, "badimport.claude.md"); await writeFile(filePath, `---\n---\n@./nonexistent.txt`); const runtime = createRuntime(); const resolved = await runtime.resolve(filePath); await expect(runtime.buildContext(resolved)).rejects.toThrow("Import not found"); }); }); describe("Structured Dry Run (ExecutionPlan)", () => { it("returns ExecutionPlan with finalPrompt when dryRun and returnPlan are true", async () => { const filePath = join(tempDir, "plan.claude.md"); await writeFile(filePath, `--- model: sonnet temperature: 0.5 --- Hello world prompt`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true }); expect(result.dryRun).toBe(true); expect(result.plan).toBeDefined(); expect(result.plan!.type).toBe("dry-run"); expect(result.plan!.finalPrompt).toBe("Hello world prompt"); expect(result.plan!.command).toBe("claude"); expect(result.plan!.args).toContain("--model"); expect(result.plan!.args).toContain("sonnet"); }); it("includes resolved imports in ExecutionPlan", async () => { const includedFile = join(tempDir, "include.txt"); await writeFile(includedFile, "Included content here"); const mainFile = join(tempDir, "main.claude.md"); await writeFile(mainFile, `---\n---\nBefore import\n@./include.txt\nAfter import`); const runtime = createRuntime(); const result = await runtime.run(mainFile, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.resolvedImports).toContain("./include.txt"); expect(result.plan!.finalPrompt).toContain("Included content here"); expect(result.plan!.finalPrompt).toContain("Before import"); expect(result.plan!.finalPrompt).toContain("After import"); }); it("includes template variables in ExecutionPlan", async () => { const filePath = join(tempDir, "template.claude.md"); await writeFile(filePath, `--- args: - name - action --- Hello {{ name }}, please {{ action }}`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true, passthroughArgs: ["World", "test"], }); expect(result.plan).toBeDefined(); expect(result.plan!.templateVars).toEqual({ name: "World", action: "test" }); expect(result.plan!.finalPrompt).toBe("Hello World, please test"); }); it("provides accurate token estimation", async () => { const filePath = join(tempDir, "tokens.claude.md"); // Create a prompt with known content const prompt = "This is a test prompt with some words to count tokens."; await writeFile(filePath, `---\n---\n${prompt}`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.estimatedTokens).toBeGreaterThan(0); // Token count should be reasonable (roughly 1 token per 4 chars, but varies) expect(result.plan!.estimatedTokens).toBeLessThan(prompt.length); }); it("includes environment variables in ExecutionPlan", async () => { const filePath = join(tempDir, "env.claude.md"); await writeFile(filePath, `--- env: API_KEY: test-key DEBUG: "true" --- Body content`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.env).toEqual({ API_KEY: "test-key", DEBUG: "true", }); }); it("includes positional mappings in ExecutionPlan", async () => { const filePath = join(tempDir, "positional.claude.md"); await writeFile(filePath, `--- $1: prompt $2: context --- Body`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.positionalMappings).toEqual({ 1: "prompt", 2: "context" }); }); it("includes full frontmatter in ExecutionPlan", async () => { const filePath = join(tempDir, "frontmatter.claude.md"); await writeFile(filePath, `--- model: opus verbose: true max-tokens: 1000 --- Body`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.frontmatter.model).toBe("opus"); expect(result.plan!.frontmatter.verbose).toBe(true); expect(result.plan!.frontmatter["max-tokens"]).toBe(1000); }); it("includes stdin content in finalPrompt", async () => { const filePath = join(tempDir, "stdin.claude.md"); await writeFile(filePath, `---\n---\nProcess this input:`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true, stdinContent: "stdin data here", }); expect(result.plan).toBeDefined(); expect(result.plan!.finalPrompt).toContain(""); expect(result.plan!.finalPrompt).toContain("stdin data here"); expect(result.plan!.finalPrompt).toContain(""); expect(result.plan!.finalPrompt).toContain("Process this input:"); }); it("tracks multiple nested imports", async () => { const file1 = join(tempDir, "level1.txt"); const file2 = join(tempDir, "level2.txt"); await writeFile(file2, "Level 2 content"); await writeFile(file1, "Level 1 start\n@./level2.txt\nLevel 1 end"); const mainFile = join(tempDir, "multi.claude.md"); await writeFile(mainFile, `---\n---\nMain\n@./level1.txt\nEnd`); const runtime = createRuntime(); const result = await runtime.run(mainFile, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.resolvedImports).toContain("./level1.txt"); expect(result.plan!.resolvedImports).toContain("./level2.txt"); expect(result.plan!.finalPrompt).toContain("Main"); expect(result.plan!.finalPrompt).toContain("Level 1 start"); expect(result.plan!.finalPrompt).toContain("Level 2 content"); expect(result.plan!.finalPrompt).toContain("Level 1 end"); expect(result.plan!.finalPrompt).toContain("End"); }); it("includes pre-hook output in finalPrompt", async () => { const filePath = join(tempDir, "prehook.claude.md"); await writeFile(filePath, `--- pre: echo "HOOK OUTPUT" --- Body content`); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: true }); expect(result.plan).toBeDefined(); expect(result.plan!.finalPrompt).toContain("HOOK OUTPUT"); expect(result.plan!.finalPrompt).toContain("Body content"); // Hook output should come before body const hookIndex = result.plan!.finalPrompt.indexOf("HOOK OUTPUT"); const bodyIndex = result.plan!.finalPrompt.indexOf("Body content"); expect(hookIndex).toBeLessThan(bodyIndex); }); it("still logs to console when returnPlan is false", async () => { const filePath = join(tempDir, "console.claude.md"); await writeFile(filePath, `---\n---\nTest prompt`); // Capture console.log output const logs: string[] = []; const originalLog = console.log; console.log = (...args: unknown[]) => logs.push(args.join(" ")); const runtime = createRuntime(); const result = await runtime.run(filePath, { dryRun: true, returnPlan: false }); console.log = originalLog; // Should still have the plan expect(result.plan).toBeDefined(); // But should also have logged to console expect(logs.some(l => l.includes("DRY RUN"))).toBe(true); expect(logs.some(l => l.includes("Test prompt"))).toBe(true); }); }); });