From 54f12580100f0bb42fb5bc2cd1a8887eaab265d8 Mon Sep 17 00:00:00 2001 From: Praveen Yadav Date: Sun, 3 May 2026 10:47:47 +0530 Subject: [PATCH] feat: add PromptResolver for inline vs promptFile (#7) First stage of the prompt pipeline. resolvePrompt() returns a discriminated-union ResolvedPrompt tagged "inline" or "template" so later slices can decide whether to substitute and expand. Wires the resolver into runProgram in place of the old "promptFile is not yet supported" branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/prompt-resolver.md | 19 +++ README.md | 7 +- packages/core/src/index.ts | 2 + packages/core/src/prompt-resolver.test.ts | 126 ++++++++++++++++++ packages/core/src/prompt-resolver.ts | 71 ++++++++++ packages/sanddune/src/internal/run-program.ts | 9 +- 6 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 .changeset/prompt-resolver.md create mode 100644 packages/core/src/prompt-resolver.test.ts create mode 100644 packages/core/src/prompt-resolver.ts diff --git a/.changeset/prompt-resolver.md b/.changeset/prompt-resolver.md new file mode 100644 index 0000000..590dd45 --- /dev/null +++ b/.changeset/prompt-resolver.md @@ -0,0 +1,19 @@ +--- +"@missingstudio/sanddune-core": patch +"@missingstudio/sanddune": patch +--- + +Add the **`PromptResolver`** — the first stage of the **prompt** pipeline. `resolvePrompt({ prompt | promptFile, promptArgs? })` returns a tagged `ResolvedPrompt`: + +- `kind: "inline"` — the verbatim string. No `{{KEY}}` scan, no shell-expression scan, no built-in argument injection (per ADR-0008's "inline = literal"). +- `kind: "template"` — the file contents read from disk plus the untouched `promptArgs` map and the resolved absolute path. **Prompt argument substitution** and **prompt expansion** are out of scope for this slice — they consume the tag in later slices. + +Runtime guards (defense in depth on the existing compile-time mutual exclusion): + +- Passing both `prompt` and `promptFile` throws. +- Passing `promptArgs` alongside an inline `prompt` throws (per ADR-0008). +- A missing `promptFile` throws an error naming the resolved absolute path. + +`promptFile` resolves relative paths against `process.cwd()` (the caller's perspective), **not** against `RunOptions.cwd` — those are deliberately different per CONTEXT.md's "two perspectives" rule. + +`run()` no longer hard-rejects `promptFile`; it now calls the resolver in place of the previous "promptFile is not yet supported" branch and feeds the resolved text into the iteration loop. Templates without `{{KEY}}` or shell expressions work today; substitution and expansion land in later slices. diff --git a/README.md b/README.md index 10f575c..b5dbc69 100644 --- a/README.md +++ b/README.md @@ -151,14 +151,17 @@ const result = await run({ | ---------------- | ------------------------------- | ------------------------------------------------------------- | | `agent` | `AgentProvider` | **Required.** Today: `claudeCode(model, { env? })` | | `sandbox` | `BindMountSandboxProvider` | **Required.** Today: `docker()` or your own bind-mount provider | -| `prompt` | `string` | **Required.** Inline prompt; passed to the agent as-is | +| `prompt` | `string` | Inline prompt; passed to the agent verbatim (ADR-0008) | +| `promptFile` | `string` | Path to a prompt template file; relative paths resolve from `process.cwd()` | | `cwd` | `string` | Host repo dir; relative paths resolve from `process.cwd()` | | `branchStrategy` | `BranchStrategy` | `head` (default) / `merge-to-head` / `branch` | | `env` | `Record` | Call-site env override; highest precedence (ADR-0012) | +Exactly one of `prompt` / `promptFile` is required. `promptFile` contents are loaded and forwarded to the agent as-is — `{{KEY}}` substitution and `` !`...` `` shell expansion are not implemented yet. Combining `prompt` with `promptArgs` throws (per ADR-0008); on the template path `promptArgs` is currently silently ignored until substitution lands. + #### Accepted by the type but not yet wired -`promptFile`, `promptArgs`, `maxIterations`, `completionSignal`, `hooks`, `timeouts`, `logging`, `signal`, `resumeSession`, `copyToWorktree`. Passing `promptFile` throws; the rest are silently ignored. Don't depend on them. +`maxIterations`, `completionSignal`, `hooks`, `timeouts`, `logging`, `signal`, `resumeSession`, `copyToWorktree`. Silently ignored. Don't depend on them. ### `RunResult` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e7a7f1c..4193f34 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,8 @@ export type { export type { Timeouts } from "./timeouts"; export type { LoggingOption } from "./logging"; export type { PromptArgs, PromptOption } from "./prompt"; +export { resolvePrompt } from "./prompt-resolver"; +export type { PromptResolverInput, ResolvedPrompt } from "./prompt-resolver"; export type { CompletionSignal, diff --git a/packages/core/src/prompt-resolver.test.ts b/packages/core/src/prompt-resolver.test.ts new file mode 100644 index 0000000..70e69b2 --- /dev/null +++ b/packages/core/src/prompt-resolver.test.ts @@ -0,0 +1,126 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { resolvePrompt } from "./prompt-resolver"; + +describe("resolvePrompt", () => { + let tmp: string; + let originalCwd: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), "sanddune-prompt-")); + originalCwd = process.cwd(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(tmp, { recursive: true, force: true }); + }); + + test("inline path returns the string verbatim", async () => { + const result = await resolvePrompt({ prompt: "do the thing {{NOT_A_KEY}}" }); + expect(result).toEqual({ + kind: "inline", + text: "do the thing {{NOT_A_KEY}}", + }); + }); + + test("template path returns file contents and the promptArgs map untouched", async () => { + const file = join(tmp, "prompt.md"); + await writeFile(file, "Work on {{ISSUE}}\n"); + + const result = await resolvePrompt({ + promptFile: file, + promptArgs: { ISSUE: "42" }, + }); + + expect(result).toEqual({ + kind: "template", + text: "Work on {{ISSUE}}\n", + promptArgs: { ISSUE: "42" }, + absolutePath: file, + }); + }); + + test("template path defaults promptArgs to an empty object when omitted", async () => { + const file = join(tmp, "prompt.md"); + await writeFile(file, "no placeholders here\n"); + + const result = await resolvePrompt({ promptFile: file }); + + expect(result.kind).toBe("template"); + if (result.kind === "template") { + expect(result.promptArgs).toEqual({}); + } + }); + + test("rejects when both prompt and promptFile are passed", async () => { + await expect( + resolvePrompt({ + prompt: "x", + promptFile: "y.md", + } as { prompt: string; promptFile: string }), + ).rejects.toThrow(/mutually exclusive/); + }); + + test("rejects when neither prompt nor promptFile is passed", async () => { + await expect(resolvePrompt({})).rejects.toThrow(/prompt is required/); + }); + + test("rejects promptArgs combined with an inline prompt", async () => { + await expect( + resolvePrompt({ + prompt: "x", + promptArgs: { K: "v" }, + } as { prompt: string; promptArgs: { K: string } }), + ).rejects.toThrow(/promptArgs.*inline/); + }); + + test("missing promptFile throws an error naming the resolved absolute path", async () => { + const missing = join(tmp, "nope.md"); + await expect(resolvePrompt({ promptFile: missing })).rejects.toThrow( + missing, + ); + }); + + test("relative promptFile resolves against process.cwd(), not the caller's other notion of cwd", async () => { + const callerDir = await mkdtemp(join(tmpdir(), "sanddune-prompt-cwd-")); + const otherDir = await mkdtemp(join(tmpdir(), "sanddune-prompt-other-")); + try { + await writeFile(join(callerDir, "prompt.md"), "from caller cwd\n"); + await writeFile(join(otherDir, "prompt.md"), "from other dir\n"); + + process.chdir(callerDir); + // macOS reports the realpath of /var/folders/... as /private/var/folders/... + // so derive the expected path from process.cwd() after chdir. + const canonicalCallerDir = process.cwd(); + + const result = await resolvePrompt({ promptFile: "prompt.md" }); + + expect(result.kind).toBe("template"); + if (result.kind === "template") { + expect(result.text).toBe("from caller cwd\n"); + expect(result.absolutePath).toBe( + resolvePath(canonicalCallerDir, "prompt.md"), + ); + } + } finally { + await rm(callerDir, { recursive: true, force: true }); + await rm(otherDir, { recursive: true, force: true }); + } + }); + + test("absolute promptFile is used as-is", async () => { + const file = join(tmp, "abs.md"); + await writeFile(file, "absolute\n"); + + const result = await resolvePrompt({ promptFile: file }); + + expect(result.kind).toBe("template"); + if (result.kind === "template") { + expect(result.absolutePath).toBe(file); + expect(result.text).toBe("absolute\n"); + } + }); +}); diff --git a/packages/core/src/prompt-resolver.ts b/packages/core/src/prompt-resolver.ts new file mode 100644 index 0000000..00cc3d8 --- /dev/null +++ b/packages/core/src/prompt-resolver.ts @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import { isAbsolute, resolve as resolvePath } from "node:path"; +import type { PromptArgs } from "./prompt"; + +export type ResolvedPrompt = + | { + readonly kind: "inline"; + readonly text: string; + } + | { + readonly kind: "template"; + readonly text: string; + readonly promptArgs: PromptArgs; + readonly absolutePath: string; + }; + +export interface PromptResolverInput { + readonly prompt?: string; + readonly promptFile?: string; + readonly promptArgs?: PromptArgs; +} + +export async function resolvePrompt( + input: PromptResolverInput, +): Promise { + const hasInline = typeof input.prompt === "string"; + const hasFile = typeof input.promptFile === "string"; + const hasArgs = input.promptArgs !== undefined; + + if (hasInline && hasFile) { + throw new Error( + "`prompt` and `promptFile` are mutually exclusive — pass one, not both.", + ); + } + if (!hasInline && !hasFile) { + throw new Error( + "A prompt is required — pass either `prompt` (inline) or `promptFile` (template).", + ); + } + + if (hasInline) { + if (hasArgs) { + throw new Error( + "`promptArgs` cannot be combined with an inline `prompt` — `promptArgs` only applies to `promptFile` templates (see ADR-0008).", + ); + } + return { kind: "inline", text: input.prompt as string }; + } + + const promptFile = input.promptFile as string; + const absolutePath = isAbsolute(promptFile) + ? promptFile + : resolvePath(process.cwd(), promptFile); + + let text: string; + try { + text = await readFile(absolutePath, "utf8"); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error(`promptFile not found: ${absolutePath}`); + } + throw cause; + } + + return { + kind: "template", + text, + promptArgs: input.promptArgs ?? {}, + absolutePath, + }; +} diff --git a/packages/sanddune/src/internal/run-program.ts b/packages/sanddune/src/internal/run-program.ts index 1a406ca..a2629c6 100644 --- a/packages/sanddune/src/internal/run-program.ts +++ b/packages/sanddune/src/internal/run-program.ts @@ -3,6 +3,7 @@ import { join, resolve as resolvePath } from "node:path"; import { AgentInvoker, resolveBranchStrategy, + resolvePrompt, type BindMountSandboxHandle, type RunOptions, type RunResult, @@ -25,11 +26,7 @@ export async function runProgram( options: RunOptions, seams: RunProgramTestSeams = {}, ): Promise { - if (typeof options.prompt !== "string") { - throw new Error( - "run() requires an inline `prompt` string in this release. `promptFile` is not yet supported.", - ); - } + const resolvedPrompt = await resolvePrompt(options); if (options.sandbox.kind !== "bind-mount") { throw new Error( `run() supports only bind-mount sandbox providers in this release; got ${options.sandbox.kind}.`, @@ -96,7 +93,7 @@ export async function runProgram( const loopResult = await Effect.runPromise( runIterationLoop({ - prompt: options.prompt, + prompt: resolvedPrompt.text, runLog, cwd: strategy.worktreePath, beforeSha,