Add scriptorium render command with shared run/request parsing and text/json prepared output

This commit is contained in:
2026-05-06 15:20:22 +00:00
parent 5328f04f6d
commit ea27763320
2 changed files with 460 additions and 83 deletions

View File

@@ -2,6 +2,7 @@ package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
@@ -16,6 +17,7 @@ import (
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
)
func TestParseMappingsSingleAndRepeated(t *testing.T) {
@@ -205,6 +207,65 @@ func TestParseRunArgsTimeout(t *testing.T) {
}
}
func TestParseRenderArgsDefaultsAndFormat(t *testing.T) {
cfg, err := parseRenderArgs([]string{
"--prompt-dir", "./prompts",
"--profile-dir", "./profiles",
"--prompt", "p",
"--input", "a=b",
})
if err != nil {
t.Fatalf("expected valid render args, got %v", err)
}
if cfg.outputFormat != renderformat.DefaultPreparedRunOutputFormat {
t.Fatalf("expected default render format %q, got %q", renderformat.DefaultPreparedRunOutputFormat, cfg.outputFormat)
}
}
func TestParseRenderArgsExplicitFormatsAndUnknown(t *testing.T) {
cfg, err := parseRenderArgs([]string{
"--prompt-dir", "./prompts",
"--profile-dir", "./profiles",
"--prompt", "p",
"--input", "a=b",
"--format", "text",
})
if err != nil {
t.Fatalf("expected valid text format, got %v", err)
}
if cfg.outputFormat != renderformat.PreparedRunFormatText {
t.Fatalf("expected text format, got %q", cfg.outputFormat)
}
cfg, err = parseRenderArgs([]string{
"--prompt-dir", "./prompts",
"--profile-dir", "./profiles",
"--prompt", "p",
"--input", "a=b",
"--format", "json",
})
if err != nil {
t.Fatalf("expected valid json format, got %v", err)
}
if cfg.outputFormat != renderformat.PreparedRunFormatJSON {
t.Fatalf("expected json format, got %q", cfg.outputFormat)
}
_, err = parseRenderArgs([]string{
"--prompt-dir", "./prompts",
"--profile-dir", "./profiles",
"--prompt", "p",
"--input", "a=b",
"--format", "yaml",
})
if err == nil {
t.Fatal("expected unknown format error")
}
if !strings.Contains(err.Error(), "unknown prepared run format") {
t.Fatalf("expected clear unknown format error, got %v", err)
}
}
func TestDetermineExitCode(t *testing.T) {
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
t.Fatalf("expected runtime exit code, got %d", got)
@@ -247,6 +308,220 @@ func TestRunCommandVarsOptional(t *testing.T) {
}
}
func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *testing.T) {
const envName = "SCRIPTORIUM_RENDER_TEST_API_KEY"
const secret = "super-secret-render-key"
t.Setenv(envName, secret)
tmp := t.TempDir()
promptDir := filepath.Join(tmp, "prompts")
profileDir := filepath.Join(tmp, "profiles")
if err := os.MkdirAll(promptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatal(err)
}
inputPath := filepath.Join(tmp, "transcript.md")
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
t.Fatal(err)
}
writePromptFileWithTemplate(t, promptDir, "prompt.render", "local-default", "Date {{.session_date}} - Summarize: {{input \"transcript\"}}")
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := renderCommand([]string{
"--prompt-dir", promptDir,
"--profile-dir", profileDir,
"--prompt", "prompt.render",
"--profile", "local-default",
"--input", "transcript=" + inputPath,
"--var", "session_date=2026-05-04",
"--llm-base-url", "http://override.local/v1",
"--model", "override-model",
"--temperature", "0.7",
"--max-tokens", "55",
"--top-p", "0.2",
"--timeout", "20s",
"--api-key-env", envName,
}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr on success, got %q", stderr.String())
}
out := stdout.String()
for _, want := range []string{
"prompt: prompt.render",
"selected_profile_id: local-default",
"endpoint: http://override.local/v1",
"model: override-model",
"temperature: 0.7",
"max_tokens: 55",
"top_p: 0.2",
"timeout_seconds: 20",
"api_key_env: " + envName,
"rendered_prompt_hash:",
"messages:",
"Date 2026-05-04",
"Summarize:",
"hello transcript",
} {
if !strings.Contains(out, want) {
t.Fatalf("expected render text output to include %q, got:\n%s", want, out)
}
}
if strings.Contains(out, secret) {
t.Fatalf("render output unexpectedly contained secret api key value: %s", out)
}
}
func TestRenderCommandExplicitTextFormatWorks(t *testing.T) {
tmp := t.TempDir()
promptDir := filepath.Join(tmp, "prompts")
profileDir := filepath.Join(tmp, "profiles")
if err := os.MkdirAll(promptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatal(err)
}
inputPath := filepath.Join(tmp, "transcript.md")
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
t.Fatal(err)
}
writePromptFile(t, promptDir, "prompt.render", "local-default")
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := renderCommand([]string{
"--prompt-dir", promptDir,
"--profile-dir", profileDir,
"--prompt", "prompt.render",
"--input", "transcript=" + inputPath,
"--format", "text",
}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "prompt: prompt.render") {
t.Fatalf("expected text output for explicit --format text, got %q", stdout.String())
}
}
func TestRenderCommandExplicitJSONFormatOutputsValidJSON(t *testing.T) {
tmp := t.TempDir()
promptDir := filepath.Join(tmp, "prompts")
profileDir := filepath.Join(tmp, "profiles")
if err := os.MkdirAll(promptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatal(err)
}
inputPath := filepath.Join(tmp, "transcript.md")
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
t.Fatal(err)
}
writePromptFile(t, promptDir, "prompt.render", "local-default")
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := renderCommand([]string{
"--prompt-dir", promptDir,
"--profile-dir", profileDir,
"--prompt", "prompt.render",
"--input", "transcript=" + inputPath,
"--format", "json",
}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("expected valid json output, got %v\nbody=%s", err, stdout.String())
}
if payload["prompt_id"] != "prompt.render" {
t.Fatalf("expected prompt_id, got %#v", payload["prompt_id"])
}
if payload["selected_profile_id"] != "local-default" {
t.Fatalf("expected selected_profile_id, got %#v", payload["selected_profile_id"])
}
if _, ok := payload["messages"]; !ok {
t.Fatalf("expected messages in render json output, got %#v", payload)
}
}
func TestRenderCommandUnknownFormatFailsClearly(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := renderCommand([]string{
"--prompt-dir", "./prompts",
"--profile-dir", "./profiles",
"--prompt", "p",
"--input", "transcript=./x.md",
"--format", "yaml",
}, &stdout, &stderr)
if code != ExitRuntimeError {
t.Fatalf("expected ExitRuntimeError, got %d", code)
}
if !strings.Contains(stderr.String(), "render parse error") || !strings.Contains(stderr.String(), "unknown prepared run format") {
t.Fatalf("expected clear unknown-format parse error, got %q", stderr.String())
}
}
func TestRenderCommandOutWritesToFile(t *testing.T) {
tmp := t.TempDir()
promptDir := filepath.Join(tmp, "prompts")
profileDir := filepath.Join(tmp, "profiles")
if err := os.MkdirAll(promptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(profileDir, 0o755); err != nil {
t.Fatal(err)
}
inputPath := filepath.Join(tmp, "transcript.md")
if err := os.WriteFile(inputPath, []byte("hello transcript"), 0o644); err != nil {
t.Fatal(err)
}
outPath := filepath.Join(tmp, "render.txt")
writePromptFile(t, promptDir, "prompt.render", "local-default")
writeProfileFile(t, profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := renderCommand([]string{
"--prompt-dir", promptDir,
"--profile-dir", profileDir,
"--prompt", "prompt.render",
"--input", "transcript=" + inputPath,
"--out", outPath,
}, &stdout, &stderr)
if code != ExitOK {
t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr.String())
}
if stdout.Len() != 0 {
t.Fatalf("expected empty stdout when --out is set, got %q", stdout.String())
}
out, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("failed reading render output file: %v", err)
}
if !strings.Contains(string(out), "prompt: prompt.render") {
t.Fatalf("expected render output in file, got %q", string(out))
}
}
func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
tmp := t.TempDir()
promptDir := filepath.Join(tmp, "prompts")
@@ -425,6 +700,11 @@ func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) {
}
func writePromptFile(t *testing.T, dir, id, defaultProfile string) {
t.Helper()
writePromptFileWithTemplate(t, dir, id, defaultProfile, "Summarize: {{input \"transcript\"}}")
}
func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) {
t.Helper()
data := fmt.Sprintf(`id: %s
version: "1.0.0"
@@ -434,12 +714,12 @@ inputs:
required: true
messages:
- role: user
content: "Summarize: {{input \"transcript\"}}"
content: %q
output:
format: text
validation_mode: none
repair_attempts: 0
`, id, defaultProfile)
`, id, defaultProfile, templateContent)
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil {
t.Fatalf("failed to write prompt fixture: %v", err)
}