Add scriptorium render command with shared run/request parsing and text/json prepared output
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user