From 55535d480b03137204b9b0f5f52c0537cdc45c23 Mon Sep 17 00:00:00 2001 From: nikhil achale Date: Sat, 23 May 2026 11:47:50 +0530 Subject: [PATCH] feat: add spawn prompt preview (#1077) --- packages/cli/__tests__/commands/spawn.test.ts | 38 ++++++++++ packages/cli/src/commands/spawn.ts | 75 ++++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 84e076ce9..d4cb3d77a 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -521,10 +521,48 @@ describe("spawn command", () => { const help = spawnCommand?.helpInformation() ?? ""; expect(help).toContain("Usage: spawn [options] [issue]"); + expect(help).toContain("--preview-prompt"); expect(help).not.toContain("[first]"); expect(help).not.toContain("[second]"); }); + it("prints a composed prompt preview without spawning or requiring the daemon", async () => { + const projects = (mockConfigRef.current as Record).projects as Record< + string, + Record + >; + projects["my-app"].agentRules = "Always write focused tests."; + projects["my-app"].agentRulesFile = "AGENTS.md"; + writeFileSync(join(tmpDir, "main-repo", "AGENTS.md"), "Prefer small changes."); + mockGetRunning.mockRejectedValue(new Error("daemon should not be checked")); + + await program.parseAsync([ + "node", + "test", + "spawn", + "INT-42", + "--preview-prompt", + "--prompt", + "Fix this\ncarefully", + ]); + + const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); + expect(output).toContain("PROMPT PREVIEW"); + expect(output).toContain("Project: my-app"); + expect(output).toContain("Issue: INT-42"); + expect(output).toContain("tracker issue context is not fetched in preview mode"); + expect(output).toContain("--- System Prompt ---"); + expect(output).toContain("## Project Context"); + expect(output).toContain("Work on issue #INT-42"); + expect(output).toContain("Always write focused tests."); + expect(output).toContain("Prefer small changes."); + expect(output).toContain("--- Task Prompt ---"); + expect(output).toContain("Fix this carefully"); + expect(mockSessionManager.spawn).not.toHaveBeenCalled(); + expect(mockRegistryGet).not.toHaveBeenCalled(); + expect(mockGetRunning).not.toHaveBeenCalled(); + }); + it("rejects more than one positional arg with replacement usage", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index cb2730b43..8655cb68a 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -3,7 +3,10 @@ import ora from "ora"; import type { Command } from "commander"; import { resolve } from "node:path"; import { + buildPrompt, + getProjectSessionsDir, loadConfig, + readMetadataRaw, recordActivityEvent, resolveSpawnTarget, TERMINAL_STATUSES, @@ -100,6 +103,58 @@ interface SpawnClaimOptions { assignOnGithub?: boolean; } +function sanitizeSpawnPrompt(prompt: string | undefined): string | undefined { + const sanitizedPrompt = prompt?.replace(/[\r\n]/g, " ").trim() || undefined; + if (sanitizedPrompt && sanitizedPrompt.length > 4096) { + throw new Error("Prompt must be at most 4096 characters"); + } + return sanitizedPrompt; +} + +function previewSpawnPrompt( + config: OrchestratorConfig, + projectId: string, + issueId?: string, + prompt?: string, +): void { + const project = config.projects[projectId]; + if (!project) { + throw new Error(`Unknown project: ${projectId}`); + } + + const sanitizedPrompt = sanitizeSpawnPrompt(prompt); + const orchestratorSessionId = `${project.sessionPrefix}-orchestrator`; + const orchestratorExists = + readMetadataRaw(getProjectSessionsDir(projectId), orchestratorSessionId) !== null; + const { systemPrompt, taskPrompt } = buildPrompt({ + project, + projectId, + issueId, + userPrompt: sanitizedPrompt, + ...(orchestratorExists && { orchestratorSessionId }), + }); + + console.log(banner("PROMPT PREVIEW")); + console.log(); + console.log(`Project: ${chalk.bold(projectId)}`); + if (issueId) { + console.log(`Issue: ${chalk.bold(issueId)}`); + console.log( + chalk.yellow( + "Note: tracker issue context is not fetched in preview mode, so network-sourced issue details are omitted.", + ), + ); + } + console.log(); + console.log(chalk.bold("--- System Prompt ---")); + console.log(systemPrompt); + if (taskPrompt) { + console.log(); + console.log(chalk.bold("--- Task Prompt ---")); + console.log(taskPrompt); + } +} + /** * Lifecycle polling runs in-process inside the long-lived `ao start` process. * `ao spawn` is a one-shot CLI — it can't start polling in its own process @@ -212,10 +267,7 @@ async function spawnSession( spinner.text = "Spawning session via core"; // Validate and sanitize prompt (strip newlines to prevent metadata injection) - const sanitizedPrompt = prompt?.replace(/[\r\n]/g, " ").trim() || undefined; - if (sanitizedPrompt && sanitizedPrompt.length > 4096) { - throw new Error("Prompt must be at most 4096 characters"); - } + const sanitizedPrompt = sanitizeSpawnPrompt(prompt); recordActivityEvent({ projectId, @@ -304,6 +356,10 @@ export function registerSpawn(program: Command): void { .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-pr ", "Immediately claim an existing PR for the spawned session") .option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user") + .option( + "--preview-prompt", + "Build and print the composed agent prompt without creating a session", + ) .option( "--prompt ", "Initial prompt/instructions for the agent (use instead of an issue)", @@ -316,6 +372,7 @@ export function registerSpawn(program: Command): void { agent?: string; claimPr?: string; assignOnGithub?: boolean; + previewPrompt?: boolean; prompt?: string; }, command: Command, @@ -346,6 +403,16 @@ export function registerSpawn(program: Command): void { process.exit(1); } + if (opts.previewPrompt) { + try { + previewSpawnPrompt(config, projectId, issueId, opts.prompt); + } catch (err) { + console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + return; + } + const claimOptions: SpawnClaimOptions = { claimPr: opts.claimPr, assignOnGithub: opts.assignOnGithub,