feat: add spawn prompt preview (#1077)

This commit is contained in:
nikhil achale 2026-05-23 11:47:50 +05:30
parent 5d0b624fbe
commit 55535d480b
2 changed files with 109 additions and 4 deletions

View File

@ -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<string, unknown>).projects as Record<
string,
Record<string, unknown>
>;
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(() => {});

View File

@ -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 <name>", "Override the agent plugin (e.g. codex, claude-code)")
.option("--claim-pr <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 <text>",
"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,