package cli import ( "bytes" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "sync/atomic" "testing" "time" "gitea.maximumdirect.net/eric/promptkit" appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" ) func TestParseMappingsSingleAndRepeated(t *testing.T) { got, err := parseMappings([]string{"transcript=./t.md", "glossary=./g.yml"}, false) if err != nil { t.Fatalf("unexpected error: %v", err) } if got["transcript"] != "./t.md" || got["glossary"] != "./g.yml" { t.Fatalf("unexpected mappings: %#v", got) } } func TestParseMappingsCommaSeparated(t *testing.T) { got, err := parseMappings([]string{"transcript=./t.md,glossary=./g.yml"}, false) if err != nil { t.Fatalf("unexpected error: %v", err) } if got["transcript"] != "./t.md" || got["glossary"] != "./g.yml" { t.Fatalf("unexpected mappings: %#v", got) } } func TestParseMappingsVarWithEqualsInValue(t *testing.T) { got, err := parseMappings([]string{"session_note=a=b=c"}, false) if err != nil { t.Fatalf("unexpected error: %v", err) } if got["session_note"] != "a=b=c" { t.Fatalf("unexpected variable value: %#v", got) } } func TestParseMappingsMalformed(t *testing.T) { tests := []string{"", "novalue", "=emptyname", "name="} for _, tc := range tests { _, err := parseMappings([]string{tc}, false) if err == nil { t.Fatalf("expected error for %q", tc) } } } func TestParseRunArgsRequiredFlags(t *testing.T) { configPath := writeAppConfigFile(t, "") _, err := parseRunArgs([]string{"--config", configPath, "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b"}) if err == nil { t.Fatal("expected missing --prompt-dir error") } if !strings.Contains(err.Error(), "prompt directory is required") { t.Fatalf("expected clear prompt-dir guidance, got %v", err) } cfg, err := parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b"}) if err != nil { t.Fatalf("expected missing --profile-dir to be accepted, got %v", err) } if cfg.profileDir != "" { t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir) } _, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--input", "a=b"}) if err == nil { t.Fatal("expected missing --prompt error") } cfg, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"}) if err != nil { t.Fatalf("expected omitted --input to be accepted, got %v", err) } if len(cfg.inputRaw) != 0 { t.Fatalf("expected no input mappings, got %#v", cfg.inputRaw) } } func TestParseRunArgsFlagMapping(t *testing.T) { cfg, err := parseRunArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "prompt.a", "--prompt-version", "2", "--profile", "profile.a", "--session-id", "session-1", "--input", "a=b", "--llm-base-url", "http://x/v1", "--model", "m", "--temperature", "0.7", "--max-tokens", "111", "--top-p", "0.8", "--reasoning-effort", "medium", "--timeout", "30s", "--api-key-env", "SCRIPTORIUM_API_KEY", }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.promptDir != filepath.Clean("./prompts") || cfg.profileDir != filepath.Clean("./profiles") { t.Fatalf("unexpected dirs: prompt=%q profile=%q", cfg.promptDir, cfg.profileDir) } if cfg.promptID != "prompt.a" || cfg.promptVersion != "2" || cfg.profileID != "profile.a" { t.Fatalf("unexpected prompt/version/profile ids: %q %q %q", cfg.promptID, cfg.promptVersion, cfg.profileID) } if cfg.sessionID != "session-1" || cfg.reasoningEffort != "medium" || !cfg.reasoningEffortSet { t.Fatalf("unexpected session or reasoning configuration: %+v", cfg) } if !cfg.llmBaseURLSet || !cfg.modelSet || !cfg.temperatureSet || !cfg.maxTokensSet || !cfg.topPSet || !cfg.timeoutSet || !cfg.apiKeyEnvSet { t.Fatalf("expected override flags set, got %+v", cfg) } } func TestParseRunArgsAllowsOmittedModelAndBaseURL(t *testing.T) { cfg, err := parseRunArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected valid args without model/base url, got %v", err) } if cfg.llmBaseURL != "" || cfg.model != "" { t.Fatalf("expected empty model/baseurl, got model=%q base=%q", cfg.model, cfg.llmBaseURL) } } func TestParseRunArgsRejectsRawLLMAPIKeyFlag(t *testing.T) { _, err := parseRunArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b", "--llm-api-key", "secret", }) if err == nil { t.Fatal("expected unknown flag error for --llm-api-key") } } func TestParseServeArgsRequiredFlags(t *testing.T) { configPath := writeAppConfigFile(t, "") _, err := parseServeArgs([]string{"--config", configPath, "--profile-dir", "./profiles"}) if err == nil { t.Fatal("expected missing --prompt-dir error") } if !strings.Contains(err.Error(), "prompt directory is required") { t.Fatalf("expected clear prompt-dir guidance, got %v", err) } cfg, err := parseServeArgs([]string{"--config", configPath, "--prompt-dir", "./prompts"}) if err != nil { t.Fatalf("expected missing --profile-dir to be accepted, got %v", err) } if cfg.profileDir != "" { t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir) } if cfg.addr != defaults.HTTPAddrDefault { t.Fatalf("expected default addr %s, got %q", defaults.HTTPAddrDefault, cfg.addr) } if cfg.schemaDir != defaults.SchemaDirDefault { t.Fatalf("expected default schema dir %q, got %q", defaults.SchemaDirDefault, cfg.schemaDir) } } func TestParseServeArgsRejectsRuntimeOverrideFlags(t *testing.T) { base := []string{"--prompt-dir", "./prompts", "--profile-dir", "./profiles"} tests := [][]string{ {"--llm-base-url", "http://localhost:8000/v1"}, {"--model", "gpt-4o-mini"}, {"--timeout", "30s"}, } for _, tc := range tests { _, err := parseServeArgs(append(base, tc...)) if err == nil { t.Fatalf("expected unknown flag error for %q", tc[0]) } } } func TestUsageIncludesExecutionAndServeFlags(t *testing.T) { var stderr bytes.Buffer code := Run(nil, io.Discard, &stderr) if code != ExitRuntimeError { t.Fatalf("expected usage path to return runtime error, got %d", code) } usage := stderr.String() for _, want := range []string{ "--prompt-version VERSION", "--session-id ID", "--reasoning-effort VALUE", "--artifact-root", "--max-request-bytes", "--max-artifact-bytes", "--max-response-bytes", } { if !strings.Contains(usage, want) { t.Fatalf("expected usage to include %q, got %q", want, usage) } } } func TestParseRunArgsTimeout(t *testing.T) { cfg, err := parseRunArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected valid run args, got %v", err) } if cfg.timeout != 0 { t.Fatalf("expected omitted timeout to remain unset, got %s", cfg.timeout) } cfg, err = parseRunArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b", "--timeout", "2m30s", }) if err != nil { t.Fatalf("expected valid run args with timeout override, got %v", err) } if cfg.timeout != 2*time.Minute+30*time.Second { t.Fatalf("expected timeout override 2m30s, got %s", cfg.timeout) } } 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 TestParseRunArgsWithExplicitConfigLoadsDirectories(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts profile_dir: ./from-config/profiles schema_dir: ./from-config/schemas `) cfg, err := parseRunArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.promptDir != filepath.Clean("./from-config/prompts") { t.Fatalf("expected prompt dir from config, got %q", cfg.promptDir) } if cfg.profileDir != filepath.Clean("./from-config/profiles") { t.Fatalf("expected profile dir from config, got %q", cfg.profileDir) } if cfg.schemaDir != filepath.Clean("./from-config/schemas") { t.Fatalf("expected schema dir from config, got %q", cfg.schemaDir) } } func TestParseRunArgsMissingExplicitConfigReturnsError(t *testing.T) { _, err := parseRunArgs([]string{ "--config", filepath.Join(t.TempDir(), "missing.yml"), "--prompt", "p", "--input", "a=b", }) if err == nil { t.Fatal("expected explicit config missing error") } } func TestParseRunArgsInvalidExplicitConfigReturnsError(t *testing.T) { configPath := writeAppConfigFile(t, "api_key: secret\n") _, err := parseRunArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err == nil { t.Fatal("expected invalid explicit config error") } if !strings.Contains(err.Error(), "application config error") { t.Fatalf("expected application config context, got %v", err) } } func TestResolveAppSettingsMissingImplicitConfigDoesNotError(t *testing.T) { fs := flag.NewFlagSet("test", flag.ContinueOnError) settings, err := resolveAppSettings(fs, filepath.Join(t.TempDir(), "missing.yml"), appconfig.CLIOverrides{}) if err != nil { t.Fatalf("expected no error, got %v", err) } if settings.SchemaDir != defaults.SchemaDirDefault { t.Fatalf("expected built-in schema dir, got %q", settings.SchemaDir) } if settings.ServerAddr != defaults.HTTPAddrDefault { t.Fatalf("expected built-in server addr, got %q", settings.ServerAddr) } } func TestParseRunArgsCLIOverridesConfigDirectories(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts profile_dir: ./from-config/profiles schema_dir: ./from-config/schemas `) cfg, err := parseRunArgs([]string{ "--config", configPath, "--prompt-dir", "./from-cli/prompts", "--profile-dir", "./from-cli/profiles", "--schema-dir", "./from-cli/schemas", "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.promptDir != filepath.Clean("./from-cli/prompts") { t.Fatalf("expected CLI prompt dir override, got %q", cfg.promptDir) } if cfg.profileDir != filepath.Clean("./from-cli/profiles") { t.Fatalf("expected CLI profile dir override, got %q", cfg.profileDir) } if cfg.schemaDir != filepath.Clean("./from-cli/schemas") { t.Fatalf("expected CLI schema dir override, got %q", cfg.schemaDir) } } func TestParseRenderArgsWithExplicitConfigLoadsDirectoriesAndFormat(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts profile_dir: ./from-config/profiles defaults: render_format: json `) cfg, err := parseRenderArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.promptDir != filepath.Clean("./from-config/prompts") { t.Fatalf("expected prompt dir from config, got %q", cfg.promptDir) } if cfg.profileDir != filepath.Clean("./from-config/profiles") { t.Fatalf("expected profile dir from config, got %q", cfg.profileDir) } if cfg.outputFormat != renderformat.PreparedRunFormatJSON { t.Fatalf("expected render format from config, got %q", cfg.outputFormat) } } func TestParseRenderArgsExplicitFormatOverridesConfigDefaultFormat(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts profile_dir: ./from-config/profiles defaults: render_format: json `) cfg, err := parseRenderArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", "--format", "text", }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.outputFormat != renderformat.PreparedRunFormatText { t.Fatalf("expected explicit --format text to override config default, got %q", cfg.outputFormat) } } func TestParseServeArgsWithExplicitConfigLoadsSettingsAndCLIAddrOverrides(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts profile_dir: ./from-config/profiles schema_dir: ./from-config/schemas server: addr: 127.0.0.1:9000 artifact_root: ./from-config/artifacts max_request_bytes: 1024 max_artifact_bytes: 2048 max_response_bytes: 4096 `) cfg, err := parseServeArgs([]string{ "--config", configPath, "--addr", ":7777", "--artifact-root", "./from-cli/artifacts", "--max-request-bytes", "0", "--max-artifact-bytes", "8192", "--max-response-bytes", "16384", }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.promptDir != filepath.Clean("./from-config/prompts") { t.Fatalf("expected prompt dir from config, got %q", cfg.promptDir) } if cfg.profileDir != filepath.Clean("./from-config/profiles") { t.Fatalf("expected profile dir from config, got %q", cfg.profileDir) } if cfg.schemaDir != filepath.Clean("./from-config/schemas") { t.Fatalf("expected schema dir from config, got %q", cfg.schemaDir) } if cfg.addr != ":7777" { t.Fatalf("expected CLI addr override, got %q", cfg.addr) } if cfg.artifactRoot != filepath.Clean("./from-cli/artifacts") { t.Fatalf("expected CLI artifact root override, got %q", cfg.artifactRoot) } if cfg.maxRequestBytes != 0 { t.Fatalf("expected CLI max request bytes override, got %d", cfg.maxRequestBytes) } if cfg.maxArtifactBytes != 8192 { t.Fatalf("expected CLI max artifact bytes override, got %d", cfg.maxArtifactBytes) } if cfg.maxResponseBytes != 16384 { t.Fatalf("expected CLI max response bytes override, got %d", cfg.maxResponseBytes) } } func TestParseServeArgsWithConfigProvidesRequiredDirectoriesAndAddr(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./from-config/prompts profile_dir: ./from-config/profiles schema_dir: ./from-config/schemas server: addr: 127.0.0.1:9000 artifact_root: ./from-config/artifacts max_request_bytes: 1024 max_artifact_bytes: 2048 max_response_bytes: 4096 `) cfg, err := parseServeArgs([]string{ "--config", configPath, }) if err != nil { t.Fatalf("expected valid args, got %v", err) } if cfg.promptDir != filepath.Clean("./from-config/prompts") { t.Fatalf("expected prompt dir from config, got %q", cfg.promptDir) } if cfg.profileDir != filepath.Clean("./from-config/profiles") { t.Fatalf("expected profile dir from config, got %q", cfg.profileDir) } if cfg.schemaDir != filepath.Clean("./from-config/schemas") { t.Fatalf("expected schema dir from config, got %q", cfg.schemaDir) } if cfg.addr != "127.0.0.1:9000" { t.Fatalf("expected addr from config, got %q", cfg.addr) } if cfg.artifactRoot != filepath.Clean("./from-config/artifacts") { t.Fatalf("expected artifact root from config, got %q", cfg.artifactRoot) } if cfg.maxRequestBytes != 1024 { t.Fatalf("expected max request bytes from config, got %d", cfg.maxRequestBytes) } if cfg.maxArtifactBytes != 2048 { t.Fatalf("expected max artifact bytes from config, got %d", cfg.maxArtifactBytes) } if cfg.maxResponseBytes != 4096 { t.Fatalf("expected max response bytes from config, got %d", cfg.maxResponseBytes) } } func TestParseServeArgsRejectsNegativeSizeLimits(t *testing.T) { tests := []struct { name string flag string }{ {name: "request", flag: "--max-request-bytes"}, {name: "artifact", flag: "--max-artifact-bytes"}, {name: "response", flag: "--max-response-bytes"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, err := parseServeArgs([]string{ "--prompt-dir", "./prompts", tc.flag, "-1", }) if err == nil { t.Fatal("expected negative size limit error") } }) } } func TestRunAndRenderRejectServeSizeLimitFlags(t *testing.T) { for _, tc := range []struct { name string parse func([]string) error }{ { name: "run", parse: func(args []string) error { _, err := parseRunArgs(args) return err }, }, { name: "render", parse: func(args []string) error { _, err := parseRenderArgs(args) return err }, }, } { t.Run(tc.name, func(t *testing.T) { err := tc.parse([]string{ "--prompt-dir", "./prompts", "--prompt", "p", "--input", "a=b", "--max-request-bytes", "1024", }) if err == nil { t.Fatal("expected unsupported flag error") } }) } } func TestRunAndRenderBuildEquivalentRuntimeOverrideRequestsForSharedFlags(t *testing.T) { runCfg, err := parseRunArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "prompt-1", "--profile", "profile-1", "--input", "transcript=./transcript.md", "--var", "session_date=2026-05-01", "--llm-base-url", "http://localhost:8000/v1", "--model", "model-x", "--temperature", "0.8", "--max-tokens", "123", "--top-p", "0.6", "--timeout", "90s", "--api-key-env", "SCRIPTORIUM_API_KEY", }) if err != nil { t.Fatalf("expected valid run args, got %v", err) } renderCfg, err := parseRenderArgs([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "prompt-1", "--profile", "profile-1", "--input", "transcript=./transcript.md", "--var", "session_date=2026-05-01", "--llm-base-url", "http://localhost:8000/v1", "--model", "model-x", "--temperature", "0.8", "--max-tokens", "123", "--top-p", "0.6", "--timeout", "90s", "--api-key-env", "SCRIPTORIUM_API_KEY", }) if err != nil { t.Fatalf("expected valid render args, got %v", err) } runReq, err := buildRunRequestFromConfig(runCfg) if err != nil { t.Fatalf("expected run request build success, got %v", err) } renderReq, err := buildRunRequestFromConfig(&renderCfg.runConfig) if err != nil { t.Fatalf("expected render request build success, got %v", err) } if !reflect.DeepEqual(runReq, renderReq) { t.Fatalf("expected run/render shared flag requests to match.\nrun=%#v\nrender=%#v", runReq, renderReq) } } func TestBuildRunRequestPreservesNumericOverridePresence(t *testing.T) { omitted, err := buildRunRequestFromConfig(&runConfig{ promptID: "prompt-1", inputRaw: []string{"transcript=./transcript.md"}, }) if err != nil { t.Fatalf("expected omitted override request to build, got %v", err) } if omitted.Execution != nil { t.Fatalf("expected omitted numeric flags to leave execution override nil, got %#v", omitted.Execution) } explicitZeros, err := buildRunRequestFromConfig(&runConfig{ promptID: "prompt-1", inputRaw: []string{"transcript=./transcript.md"}, temperatureSet: true, maxTokensSet: true, topPSet: true, timeoutSet: true, }) if err != nil { t.Fatalf("expected explicit zero override request to build, got %v", err) } if explicitZeros.Execution == nil { t.Fatal("expected explicit numeric flags to create execution override") } if explicitZeros.Execution.Temperature == nil || explicitZeros.Execution.MaxTokens == nil || explicitZeros.Execution.TopP == nil || explicitZeros.Execution.TimeoutSeconds == nil { t.Fatalf("expected explicit zero numeric overrides to remain non-nil, got %#v", explicitZeros.Execution) } if *explicitZeros.Execution.Temperature != 0 || *explicitZeros.Execution.MaxTokens != 0 || *explicitZeros.Execution.TopP != 0 || *explicitZeros.Execution.TimeoutSeconds != 0 { t.Fatalf("expected explicit numeric overrides to retain zero values, got %#v", explicitZeros.Execution) } } func TestBuildRunRequestPreservesReasoningEffortPresenceAndSessionID(t *testing.T) { omitted, err := buildRunRequestFromConfig(&runConfig{promptID: "prompt-1"}) if err != nil { t.Fatalf("expected omitted request to build, got %v", err) } if omitted.Execution != nil { t.Fatalf("expected omitted reasoning flag to leave execution nil, got %#v", omitted.Execution) } for _, tc := range []struct { name string value string }{ {name: "replacement", value: "high"}, {name: "clear", value: ""}, } { t.Run(tc.name, func(t *testing.T) { req, err := buildRunRequestFromConfig(&runConfig{ promptID: "prompt-1", sessionID: "session-1", reasoningEffort: tc.value, reasoningEffortSet: true, }) if err != nil { t.Fatalf("expected request to build, got %v", err) } if req.SessionID != "session-1" || req.Execution == nil || req.Execution.ReasoningEffort == nil || *req.Execution.ReasoningEffort != tc.value { t.Fatalf("unexpected mapped request: %#v", req) } }) } } func TestBuildRunRequestAllowsOmittedInputsAndMapsPromptVersion(t *testing.T) { req, err := buildRunRequestFromConfig(&runConfig{ promptID: "prompt-1", promptVersion: "2", }) if err != nil { t.Fatalf("expected request without inputs to build, got %v", err) } if req.PromptVersion != "2" { t.Fatalf("expected prompt version to be mapped, got %q", req.PromptVersion) } if req.Inputs != nil { t.Fatalf("expected omitted inputs to remain nil, got %#v", req.Inputs) } } func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) { configPath := writeAppConfigFile(t, ` profile_dir: ./profiles `) _, err := parseRunArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err == nil { t.Fatal("expected missing prompt_dir error") } if !strings.Contains(err.Error(), "prompt directory is required") || !strings.Contains(err.Error(), "config.yml prompt_dir") { t.Fatalf("expected clear prompt_dir guidance, got %v", err) } } func TestParseRunArgsAcceptsMissingEffectiveProfileDir(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./prompts `) cfg, err := parseRunArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected missing profile_dir to be accepted, got %v", err) } if cfg.profileDir != "" { t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir) } } func TestParseRenderArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) { configPath := writeAppConfigFile(t, ` profile_dir: ./profiles `) _, err := parseRenderArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err == nil { t.Fatal("expected missing prompt_dir error") } if !strings.Contains(err.Error(), "prompt directory is required") || !strings.Contains(err.Error(), "config.yml prompt_dir") { t.Fatalf("expected clear prompt_dir guidance, got %v", err) } } func TestParseRenderArgsAcceptsMissingEffectiveProfileDir(t *testing.T) { configPath := writeAppConfigFile(t, ` prompt_dir: ./prompts `) cfg, err := parseRenderArgs([]string{ "--config", configPath, "--prompt", "p", "--input", "a=b", }) if err != nil { t.Fatalf("expected missing profile_dir to be accepted, got %v", err) } if cfg.profileDir != "" { t.Fatalf("expected empty profile dir for built-ins, got %q", cfg.profileDir) } } func TestDetermineExitCode(t *testing.T) { if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError { t.Fatalf("expected runtime exit code, got %d", got) } if got := determineExitCode(nil, &promptkit.RunResult{Validation: promptkit.ValidationResult{Status: promptkit.ValidationFailed}}); got != ExitValidationFailed { t.Fatalf("expected validation exit code, got %d", got) } if got := determineExitCode(nil, &promptkit.RunResult{Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed}}); got != ExitOK { t.Fatalf("expected success exit code for passed validation, got %d", got) } if got := determineExitCode(nil, &promptkit.RunResult{Validation: promptkit.ValidationResult{Status: promptkit.ValidationSkipped}}); got != ExitOK { t.Fatalf("expected success exit code for skipped validation, got %d", got) } } func TestRunCommandVarsOptional(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := runCommand([]string{ "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p", "--input", "transcript=./t.md", "--llm-base-url", "http://[::1", "--model", "m", }, &stdout, &stderr) if code != ExitRuntimeError { t.Fatalf("expected runtime error exit code, got %d", code) } if strings.Contains(stderr.String(), "var parse error") { t.Fatalf("expected --var to be optional, got stderr=%q", stderr.String()) } if !strings.Contains(stderr.String(), "llm client error") && !strings.Contains(stderr.String(), "run error") { t.Fatalf("expected post-parse execution error, got stderr=%q", stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected no stdout output on error, got %q", stdout.String()) } } func TestRunCommandSucceedsWithPromptAndProfileDirsFromConfig(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") ts := newTestLLMServer("from-config-dirs", nil) defer ts.Close() writePromptFile(t, lib.promptDir, "prompt.default", "local-default") writeProfileFile(t, lib.profileDir, "local-default", ts.URL+"/v1", "profile-model") configPath := writeAppConfigFile(t, fmt.Sprintf(` prompt_dir: %s profile_dir: %s `, lib.promptDir, lib.profileDir)) code, stdout, stderr := runCLICommand(t, runCommand, []string{ "--config", configPath, "--prompt", "prompt.default", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if stdout != "from-config-dirs" { t.Fatalf("unexpected stdout output: %q", stdout) } } func TestRenderCommandDefaultFormatTextIncludesPreparedDetailsAndNoSecrets(t *testing.T) { const envName = "SCRIPTORIUM_RENDER_TEST_API_KEY" const secret = "super-secret-render-key" t.Setenv(envName, secret) lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") writePromptFileWithTemplate(t, lib.promptDir, "prompt.render", "local-default", "Date {{.session_date}} - Summarize: {{input \"transcript\"}}") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.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, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if stderr != "" { t.Fatalf("expected empty stderr on success, got %q", stderr) } out := stdout 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 TestRenderCommandExplicitZeroTemperatureReachesEffectiveSettings(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") writePromptFile(t, lib.promptDir, "prompt.render", "local-default") profile := `id: local-default endpoint: http://127.0.0.1:1/v1 model: profile-model temperature: 0.7 ` if err := os.WriteFile(filepath.Join(lib.profileDir, "local-default.yaml"), []byte(profile), 0o644); err != nil { t.Fatalf("failed to write profile fixture: %v", err) } code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.render", "--input", "transcript=" + inputPath, "--temperature", "0", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, "\n temperature: 0\n") { t.Fatalf("expected explicit zero temperature in effective settings, got:\n%s", stdout) } } func TestRenderCommandSucceedsWithPromptAndProfileDirsFromConfig(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") writePromptFile(t, lib.promptDir, "prompt.render", "local-default") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") configPath := writeAppConfigFile(t, fmt.Sprintf(` prompt_dir: %s profile_dir: %s `, lib.promptDir, lib.profileDir)) code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--config", configPath, "--prompt", "prompt.render", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, "prompt: prompt.render") { t.Fatalf("expected rendered output, got %q", stdout) } } func TestRenderCommandUsesConfiguredCustomBackend(t *testing.T) { lib := newCLITestLibrary(t) writePromptDefinition(t, lib.promptDir, "custom.yaml", `id: custom version: "1" default_profile: local-gpu messages: - role: user content: "hello" output: format: text validation_mode: none `) if err := os.WriteFile(filepath.Join(lib.profileDir, "local-gpu.yaml"), []byte(`id: local-gpu backend: local-gpu model: local-model `), 0o644); err != nil { t.Fatalf("write profile fixture: %v", err) } configPath := writeAppConfigFile(t, fmt.Sprintf(` prompt_dir: %s profile_dir: %s backends: local-gpu: endpoint: http://localhost:11434/v1 extra_params: provider_option: enabled concurrency_limit: 2 queue_capacity: 0 `, lib.promptDir, lib.profileDir)) code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--config", configPath, "--prompt", "custom", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, "selected_backend_id: local-gpu") { t.Fatalf("expected configured backend in prepared output, got:\n%s", stdout) } } func TestRenderCommandMapsReasoningEffortAndSessionID(t *testing.T) { lib := newCLITestLibrary(t) writePromptDefinition(t, lib.promptDir, "session.yaml", `id: session version: "1" default_profile: local session_id: definition-session messages: - role: user content: "hello" output: format: text validation_mode: none `) if err := os.WriteFile(filepath.Join(lib.profileDir, "local.yaml"), []byte(`id: local endpoint: http://127.0.0.1:1/v1 model: local-model reasoning_effort: low `), 0o644); err != nil { t.Fatalf("write profile fixture: %v", err) } for _, tc := range []struct { name string args []string wantReasoning string wantSessionID string absentReasoning bool }{ {name: "omitted reasoning inherits profile", wantReasoning: "low", wantSessionID: "definition-session"}, {name: "nonblank reasoning replaces profile", args: []string{"--reasoning-effort", "high"}, wantReasoning: "high", wantSessionID: "definition-session"}, {name: "empty reasoning clears profile", args: []string{"--reasoning-effort="}, wantSessionID: "definition-session", absentReasoning: true}, {name: "direct session replaces definition", args: []string{"--session-id", "direct-session"}, wantReasoning: "low", wantSessionID: "direct-session"}, } { t.Run(tc.name, func(t *testing.T) { args := []string{"--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "session"} args = append(args, tc.args...) code, stdout, stderr := runCLICommand(t, renderCommand, args) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, "session_id: "+tc.wantSessionID) { t.Fatalf("expected session ID %q, got:\n%s", tc.wantSessionID, stdout) } hasReasoning := strings.Contains(stdout, "reasoning_effort:") if tc.absentReasoning { if hasReasoning { t.Fatalf("expected cleared reasoning to be omitted, got:\n%s", stdout) } return } if !strings.Contains(stdout, "reasoning_effort: "+tc.wantReasoning) { t.Fatalf("expected reasoning effort %q, got:\n%s", tc.wantReasoning, stdout) } }) } } func TestRenderCommandOmitsEmptyEffectiveSessionID(t *testing.T) { lib := newCLITestLibrary(t) writePromptDefinition(t, lib.promptDir, "plain.yaml", `id: plain version: "1" default_profile: local messages: - role: user content: "hello" output: format: text validation_mode: none `) writeProfileFile(t, lib.profileDir, "local", "http://127.0.0.1:1/v1", "local-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "plain", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if strings.Contains(stdout, "session_id:") { t.Fatalf("expected no effective session ID, got:\n%s", stdout) } } func TestRenderCommandRejectsOverlongSessionID(t *testing.T) { lib := newCLITestLibrary(t) writePromptDefinition(t, lib.promptDir, "session.yaml", `id: session version: "1" default_profile: local messages: - role: user content: "hello" output: format: text validation_mode: none `) writeProfileFile(t, lib.profileDir, "local", "http://127.0.0.1:1/v1", "local-model") code, _, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "session", "--session-id", strings.Repeat("x", 257), }) if code != ExitRuntimeError { t.Fatalf("expected runtime error, got %d stderr=%q", code, stderr) } if !strings.Contains(stderr, "invalid run request") { t.Fatalf("expected invalid-request context, got %q", stderr) } } func TestInspectPromptCommandFormatsDefinitionWithoutProfileOrGeneration(t *testing.T) { lib := newCLITestLibrary(t) writePromptDefinition(t, lib.promptDir, "inspect.yaml", `id: inspect version: "1" messages: - role: user content: "hello" output: format: text validation_mode: none `) code, stdout, stderr := runCLICommand(t, inspectCommand, []string{"prompt", "--prompt-dir", lib.promptDir, "--prompt", "inspect", "--format", "json"}) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, `"prompt_id": "inspect"`) || !strings.Contains(stdout, `"inputs": []`) { t.Fatalf("unexpected inspection output: %s", stdout) } code, stdout, stderr = runCLICommand(t, inspectCommand, []string{"prompt", "--prompt-dir", lib.promptDir, "--prompt", "missing"}) if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "inspect error") { t.Fatalf("expected failed inspection without output, got code=%d stdout=%q stderr=%q", code, stdout, stderr) } } func TestPromptkitV09DefinitionsRenderThroughCLI(t *testing.T) { fixtureRoot := promptkitV09FixtureRoot(t) configPath := writePromptkitV09Config(t, fixtureRoot, true) inputPath := filepath.Join(fixtureRoot, "inputs", "source.md") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--config", configPath, "--prompt", "compat.complete", "--prompt-version", "1.0.0", "--input", "source=" + inputPath, "--var", "topic=testing", "--format", "json", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } var payload map[string]any if err := json.Unmarshal([]byte(stdout), &payload); err != nil { t.Fatalf("decode rendered fixture: %v\nbody=%s", err, stdout) } if payload["prompt_version"] != "1.0.0" || payload["selected_profile_id"] != "custom-derived" || payload["selected_backend_id"] != "fixture-custom" { t.Fatalf("unexpected selected definition and target: %#v", payload) } if payload["session_id"] != "fixture-testing" { t.Fatalf("expected rendered session template, got %#v", payload["session_id"]) } outputContract := payload["output_contract"].(map[string]any) if outputContract["format"] != "json" || outputContract["validation_mode"] != "json_schema" || outputContract["repair_attempts"] != float64(2) { t.Fatalf("unexpected output contract: %#v", outputContract) } if _, ok := payload["structured_output"].(map[string]any)["json_schema"]; !ok { t.Fatalf("expected loaded JSON schema metadata, got %#v", payload["structured_output"]) } messages := payload["messages"].([]any) wantRoles := []string{"developer", "system", "user", "assistant"} for i, wantRole := range wantRoles { message := messages[i].(map[string]any) if message["role"] != wantRole { t.Fatalf("message %d: expected role %q, got %#v", i, wantRole, message["role"]) } } cacheControl := messages[0].(map[string]any)["cache_control"].(map[string]any) if cacheControl["type"] != "ephemeral" || cacheControl["ttl"] != "1h" { t.Fatalf("unexpected cache control: %#v", cacheControl) } if !strings.Contains(messages[2].(map[string]any)["content"].(string), "stable, synthetic material") { t.Fatalf("file-backed input template was not rendered: %#v", messages[2]) } inputHashes := payload["input_hashes"].(map[string]any) if len(inputHashes) != 1 || inputHashes["source"] == "" { t.Fatalf("required and omitted optional inputs were not preserved: %#v", inputHashes) } } func TestPromptkitV09PromptInspectionSelectsVersionsAndContracts(t *testing.T) { fixtureRoot := promptkitV09FixtureRoot(t) configPath := writePromptkitV09Config(t, fixtureRoot, true) tests := []struct { promptID string version string format string mode string }{ {promptID: "compat.complete", version: "1.0.0", format: "json", mode: "json_schema"}, {promptID: "compat.complete", version: "2.0.0", format: "text", mode: "basic"}, {promptID: "compat.json", version: "1.0.0", format: "json", mode: "json"}, {promptID: "compat.none", version: "1.0.0", format: "text", mode: "none"}, } for _, tc := range tests { t.Run(tc.promptID+"@"+tc.version, func(t *testing.T) { code, stdout, stderr := runCLICommand(t, inspectCommand, []string{ "prompt", "--config", configPath, "--prompt", tc.promptID, "--prompt-version", tc.version, "--format", "json", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } var inspection renderformat.PromptInspection if err := json.Unmarshal([]byte(stdout), &inspection); err != nil { t.Fatalf("decode prompt inspection: %v\nbody=%s", err, stdout) } if inspection.PromptVersion != tc.version || inspection.OutputContract.Format != tc.format || inspection.OutputContract.ValidationMode != tc.mode { t.Fatalf("unexpected prompt inspection: %+v", inspection) } }) } } func TestPromptkitV09ProfileInspectionResolvesSupportedTargets(t *testing.T) { const secret = "sentinel-profile-secret" t.Setenv("FIXTURE_PROFILE_API_KEY", secret) fixtureRoot := promptkitV09FixtureRoot(t) configPath := writePromptkitV09Config(t, fixtureRoot, false) profileDir := filepath.Join(fixtureRoot, "profiles") tests := []struct { name string profileID string profileDir string wantBackend string wantModel string wantAPIKeyEnv string }{ {name: "inherited custom backend", profileID: "custom-derived", profileDir: profileDir, wantBackend: "fixture-custom", wantModel: "fixture-derived-model", wantAPIKeyEnv: "FIXTURE_PROFILE_API_KEY"}, {name: "endpoint only", profileID: "endpoint-only", profileDir: profileDir, wantModel: "fixture-endpoint-model"}, {name: "built in", profileID: "deepseek-4-flash", wantBackend: "openrouter", wantModel: "deepseek/deepseek-v4-flash", wantAPIKeyEnv: "OPENROUTER_API_KEY"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { args := []string{"profile", "--config", configPath, "--profile", tc.profileID, "--format", "json"} if tc.profileDir != "" { args = append(args, "--profile-dir", tc.profileDir) } code, stdout, stderr := runCLICommand(t, inspectCommand, args) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if strings.Contains(stdout, secret) || strings.Contains(stderr, secret) { t.Fatalf("inspection exposed environment secret: stdout=%q stderr=%q", stdout, stderr) } var inspection renderformat.ProfileInspection if err := json.Unmarshal([]byte(stdout), &inspection); err != nil { t.Fatalf("decode profile inspection: %v\nbody=%s", err, stdout) } params := inspection.EffectiveModelParams if inspection.ProfileID != tc.profileID || params.BackendID != tc.wantBackend || params.Model != tc.wantModel || params.APIKeyEnv != tc.wantAPIKeyEnv { t.Fatalf("unexpected effective profile: %+v", inspection) } if tc.profileID == "custom-derived" { if params.ServiceTier != "flex" || params.ReasoningEffort != "high" || params.TimeoutSeconds != 45 || len(params.ExtraParams) == 0 { t.Fatalf("inherited profile controls were not resolved: %+v", params) } } }) } } func TestProfileInspectionHonorsDirectoryPrecedenceOutputAndFailures(t *testing.T) { fixtureRoot := promptkitV09FixtureRoot(t) configPath := writePromptkitV09Config(t, fixtureRoot, false) overrideDir := t.TempDir() writeProfileFile(t, overrideDir, "custom-derived", "http://127.0.0.1:9000/v1", "override-model") outPath := filepath.Join(t.TempDir(), "inspection.json") code, stdout, stderr := runCLICommand(t, inspectCommand, []string{ "profile", "--config", configPath, "--profile-dir", overrideDir, "--profile", "custom-derived", "--format", "json", "--out", outPath, }) if code != ExitOK || stdout != "" { t.Fatalf("expected file output, got code=%d stdout=%q stderr=%q", code, stdout, stderr) } output, err := os.ReadFile(outPath) if err != nil { t.Fatalf("read profile inspection output: %v", err) } if !strings.Contains(string(output), `"model": "override-model"`) || !strings.Contains(string(output), `"backend_id": ""`) { t.Fatalf("profile directory override was not used: %s", output) } for _, profileID := range []string{"missing", "invalid"} { t.Run(profileID, func(t *testing.T) { profileDir := filepath.Join(fixtureRoot, "profiles") if profileID == "invalid" { profileDir = t.TempDir() writePromptDefinition(t, profileDir, "invalid.yaml", "id: invalid\nendpoint: not-a-url\nmodel: fixture\n") } code, stdout, stderr := runCLICommand(t, inspectCommand, []string{ "profile", "--config", configPath, "--profile-dir", profileDir, "--profile", profileID, }) if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "inspect error") { t.Fatalf("expected inspection failure without partial output, got code=%d stdout=%q stderr=%q", code, stdout, stderr) } }) } } func TestConfiguredBackendValidationComesFromPromptkit(t *testing.T) { lib := newCLITestLibrary(t) for _, tc := range []struct { name string backend string }{ { name: "reserved ID", backend: `openrouter: endpoint: http://localhost:11434/v1`, }, { name: "invalid endpoint", backend: `local-gpu: endpoint: not-a-url`, }, { name: "invalid capacity relationship", backend: `local-gpu: endpoint: http://localhost:11434/v1 queue_capacity: 0`, }, { name: "reserved extra parameter", backend: `local-gpu: endpoint: http://localhost:11434/v1 extra_params: model: forbidden`, }, } { t.Run(tc.name, func(t *testing.T) { configPath := writeAppConfigFile(t, fmt.Sprintf(` prompt_dir: %s profile_dir: %s backends: %s `, lib.promptDir, lib.profileDir, tc.backend)) cfg, err := parseRenderArgs([]string{"--config", configPath, "--prompt", "custom"}) if err != nil { t.Fatalf("expected config decoding to succeed, got %v", err) } _, err = newEngine(cfg.runConfig.engineSettings()) if !errors.Is(err, promptkit.ErrInvalidConfig) { t.Fatalf("expected Promptkit ErrInvalidConfig, got %v", err) } if !strings.Contains(err.Error(), "engine initialization from application configuration") { t.Fatalf("expected application context, got %v", err) } }) } } func TestRenderCommandExplicitTextFormatWorks(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") writePromptFile(t, lib.promptDir, "prompt.render", "local-default") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.render", "--input", "transcript=" + inputPath, "--format", "text", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, "prompt: prompt.render") { t.Fatalf("expected text output for explicit --format text, got %q", stdout) } } func TestRenderCommandExplicitJSONFormatOutputsValidJSON(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") writePromptFile(t, lib.promptDir, "prompt.render", "local-default") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.render", "--input", "transcript=" + inputPath, "--format", "json", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } var payload map[string]any if err := json.Unmarshal([]byte(stdout), &payload); err != nil { t.Fatalf("expected valid json output, got %v\nbody=%s", err, stdout) } 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) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript") outPath := filepath.Join(lib.rootDir, "render.txt") writePromptFile(t, lib.promptDir, "prompt.render", "local-default") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.render", "--input", "transcript=" + inputPath, "--out", outPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if stdout != "" { t.Fatalf("expected empty stdout when --out is set, got %q", stdout) } 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 TestRenderCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") writePromptFile(t, lib.promptDir, "prompt.default", "local-default") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "default-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.default", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } out := stdout if !strings.Contains(out, "selected_profile_id: local-default") { t.Fatalf("expected prompt default profile in output, got %q", out) } if !strings.Contains(out, "model: default-model") { t.Fatalf("expected model from default profile in output, got %q", out) } } func TestRenderCommandUsesBuiltInProfileWithoutProfileDir(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") writePromptFile(t, lib.promptDir, "prompt.builtin", "mistral-small-3") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--prompt", "prompt.builtin", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if !strings.Contains(stdout, "selected_profile_id: mistral-small-3") { t.Fatalf("expected built-in selected profile, got %q", stdout) } if !strings.Contains(stdout, "model: mistralai/mistral-small-3.2-24b-instruct") { t.Fatalf("expected built-in model, got %q", stdout) } } func TestRenderCommandExplicitProfileOverridesPromptDefault(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") writePromptFile(t, lib.promptDir, "prompt.default", "local-default") writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "default-model") writeProfileFile(t, lib.profileDir, "quality", "http://127.0.0.1:1/v1", "quality-model") code, stdout, stderr := runCLICommand(t, renderCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.default", "--profile", "quality", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } out := stdout if !strings.Contains(out, "selected_profile_id: quality") { t.Fatalf("expected explicit profile in output, got %q", out) } if !strings.Contains(out, "model: quality-model") { t.Fatalf("expected model from explicit profile in output, got %q", out) } } func TestRenderCommandUsesDefinitionInputRulesAndPromptVersions(t *testing.T) { lib := newCLITestLibrary(t) writeProfileFile(t, lib.profileDir, "local", "http://127.0.0.1:1/v1", "model") writePromptDefinition(t, lib.promptDir, "sole.yaml", `id: sole version: "1" default_profile: local messages: - role: user content: "hello" output: format: text validation_mode: none `) writePromptDefinition(t, lib.promptDir, "versioned-one.yaml", `id: versioned version: "1" default_profile: local messages: - role: user content: "one" output: format: text validation_mode: none `) writePromptDefinition(t, lib.promptDir, "versioned-two.yaml", `id: versioned version: "2" default_profile: local messages: - role: user content: "two" output: format: text validation_mode: none `) writePromptDefinition(t, lib.promptDir, "optional.yaml", `id: optional version: "1" default_profile: local inputs: - name: note required: false messages: - role: user content: "hello" output: format: text validation_mode: none `) writePromptDefinition(t, lib.promptDir, "required.yaml", `id: required version: "1" default_profile: local inputs: - name: note required: true messages: - role: user content: "hello" output: format: text validation_mode: none `) writePromptDefinition(t, lib.promptDir, "template.yaml", `id: template version: "1" default_profile: local messages: - role: user content: '{{input "note"}}' output: format: text validation_mode: none `) baseArgs := []string{"--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir} for _, tc := range []struct { name string args []string wantCode int wantText string }{ {name: "sole version selected when omitted", args: []string{"--prompt", "sole"}, wantCode: ExitOK, wantText: "prompt_version: 1"}, {name: "explicit version selected", args: []string{"--prompt", "versioned", "--prompt-version", "2"}, wantCode: ExitOK, wantText: "prompt_version: 2"}, {name: "multiple versions require selection", args: []string{"--prompt", "versioned"}, wantCode: ExitRuntimeError, wantText: "duplicate prompt definition id"}, {name: "no declared inputs", args: []string{"--prompt", "sole"}, wantCode: ExitOK, wantText: "prompt: sole"}, {name: "optional input omitted", args: []string{"--prompt", "optional"}, wantCode: ExitOK, wantText: "prompt: optional"}, {name: "required input omitted", args: []string{"--prompt", "required"}, wantCode: ExitRuntimeError, wantText: "required"}, {name: "template input omitted", args: []string{"--prompt", "template"}, wantCode: ExitRuntimeError, wantText: "note"}, } { t.Run(tc.name, func(t *testing.T) { code, stdout, stderr := runCLICommand(t, renderCommand, append(append([]string{}, baseArgs...), tc.args...)) if code != tc.wantCode { t.Fatalf("expected exit %d, got %d stderr=%q", tc.wantCode, code, stderr) } if !strings.Contains(stdout+stderr, tc.wantText) { t.Fatalf("expected output to contain %q, stdout=%q stderr=%q", tc.wantText, stdout, stderr) } }) } } func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") ts := newTestLLMServer("default-output", nil) defer ts.Close() writePromptFile(t, lib.promptDir, "prompt.default", "local-default") writeProfileFile(t, lib.profileDir, "local-default", ts.URL+"/v1", "profile-model") code, stdout, stderr := runCLICommand(t, runCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.default", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if stdout != "default-output" { t.Fatalf("unexpected stdout output: %q", stdout) } if !strings.Contains(stderr, "selected_profile=local-default") { t.Fatalf("expected selected profile in summary, got %q", stderr) } } func TestRunCommandExplicitProfileOverridesPromptDefault(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") defaultServer := newTestLLMServer("from-default", nil) defer defaultServer.Close() overrideServer := newTestLLMServer("from-override", nil) defer overrideServer.Close() writePromptFile(t, lib.promptDir, "prompt.default", "local-default") writeProfileFile(t, lib.profileDir, "local-default", defaultServer.URL+"/v1", "default-model") writeProfileFile(t, lib.profileDir, "quality", overrideServer.URL+"/v1", "quality-model") code, stdout, stderr := runCLICommand(t, runCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.default", "--profile", "quality", "--input", "transcript=" + inputPath, }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if stdout != "from-override" { t.Fatalf("expected explicit profile output, got %q", stdout) } if !strings.Contains(stderr, "selected_profile=quality") { t.Fatalf("expected selected profile quality, got %q", stderr) } } func TestRunCommandRuntimeFlagsOverrideSelectedProfileValues(t *testing.T) { lib := newCLITestLibrary(t) inputPath := lib.writeInputFile(t, "transcript.md", "hello") var baseHits int32 baseServer := newTestLLMServer("base", &baseHits) defer baseServer.Close() var overrideHits int32 var observedBody string overrideServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&overrideHits, 1) body, _ := io.ReadAll(r.Body) observedBody = string(body) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"override"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) })) defer overrideServer.Close() writePromptFile(t, lib.promptDir, "prompt.default", "local-default") writeProfileFile(t, lib.profileDir, "local-default", baseServer.URL+"/v1", "profile-model") code, stdout, stderr := runCLICommand(t, runCommand, []string{ "--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir, "--prompt", "prompt.default", "--input", "transcript=" + inputPath, "--llm-base-url", overrideServer.URL + "/v1", "--model", "override-model", "--temperature", "0.7", "--max-tokens", "55", "--top-p", "0.2", "--timeout", "20s", }) if code != ExitOK { t.Fatalf("expected ExitOK, got %d stderr=%q", code, stderr) } if atomic.LoadInt32(&baseHits) != 0 { t.Fatalf("expected base profile endpoint not to be hit, got %d", baseHits) } if atomic.LoadInt32(&overrideHits) != 1 { t.Fatalf("expected override endpoint to be hit once, got %d", overrideHits) } if stdout != "override" { t.Fatalf("unexpected stdout output: %q", stdout) } if !strings.Contains(observedBody, `"model":"override-model"`) { t.Fatalf("expected override model in request body, got %s", observedBody) } if !strings.Contains(observedBody, `"temperature":0.7`) || !strings.Contains(observedBody, `"max_tokens":55`) || !strings.Contains(observedBody, `"top_p":0.2`) { t.Fatalf("expected override generation params in request body, got %s", observedBody) } } func TestWriteOutputAndSummaryUseSeparateWriters(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer if err := writeOutput(&stdout, "", []byte("artifact-body")); err != nil { t.Fatalf("unexpected writeOutput error: %v", err) } printSummary(&stderr, &promptkit.RunResult{ PromptID: "p", PromptVersion: "1", SelectedProfileID: "exec", ModelName: "m", Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic}, RenderedPromptHash: "h", InputHashes: map[string]string{"in": "x"}, }) if stdout.String() != "artifact-body" { t.Fatalf("expected artifact output on stdout, got %q", stdout.String()) } if !strings.Contains(stderr.String(), "prompt=p@1") { t.Fatalf("expected summary on stderr, got %q", stderr.String()) } if strings.Contains(stderr.String(), "cached_tokens=") || strings.Contains(stderr.String(), "cache_write_tokens=") { t.Fatalf("expected zero cache usage to be omitted from summary, got %q", stderr.String()) } if strings.Contains(stderr.String(), "backend=") { t.Fatalf("expected endpoint-only backend to be omitted from summary, got %q", stderr.String()) } } func TestPrintSummaryIncludesCacheUsageWhenPresent(t *testing.T) { var stderr bytes.Buffer printSummary(&stderr, &promptkit.RunResult{ PromptID: "p", PromptVersion: "1", SelectedProfileID: "exec", ModelName: "m", Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic}, RenderedPromptHash: "h", InputHashes: map[string]string{"in": "x"}, Usage: promptkit.TokenUsage{ PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, CachedTokens: 0, CacheWriteTokens: 3, }, }) summary := stderr.String() if !strings.Contains(summary, "usage=10/5/15") { t.Fatalf("expected base usage summary, got %q", summary) } if !strings.Contains(summary, "cached_tokens=0 cache_write_tokens=3") { t.Fatalf("expected cache usage in summary, got %q", summary) } if strings.Contains(summary, "backend=") { t.Fatalf("expected endpoint-only backend to be omitted from summary, got %q", summary) } } func TestPrintSummaryIncludesBackendWhenPresent(t *testing.T) { var stderr bytes.Buffer printSummary(&stderr, &promptkit.RunResult{ PromptID: "p", PromptVersion: "1", SelectedProfileID: "exec", SelectedBackendID: "local", ModelName: "m", Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationBasic}, RenderedPromptHash: "h", }) if !strings.Contains(stderr.String(), "backend=local") { t.Fatalf("expected backend in summary, got %q", stderr.String()) } } func TestRunErrorMessageDoesNotExposeCapacityDetails(t *testing.T) { got := runErrorMessage(&promptkit.CapacityError{BackendID: "private-backend"}) if got != "run error: model backend capacity is exhausted" { t.Fatalf("unexpected capacity diagnostic: %q", got) } } type cliTestLibrary struct { rootDir string promptDir string profileDir string } func newCLITestLibrary(t *testing.T) *cliTestLibrary { t.Helper() root := t.TempDir() lib := &cliTestLibrary{ rootDir: root, promptDir: filepath.Join(root, "prompts"), profileDir: filepath.Join(root, "profiles"), } if err := os.MkdirAll(lib.promptDir, 0o755); err != nil { t.Fatalf("failed to create prompt fixture directory: %v", err) } if err := os.MkdirAll(lib.profileDir, 0o755); err != nil { t.Fatalf("failed to create profile fixture directory: %v", err) } return lib } func (l *cliTestLibrary) writeInputFile(t *testing.T, name, body string) string { t.Helper() path := filepath.Join(l.rootDir, name) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("failed to create input fixture directory: %v", err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatalf("failed to write input fixture: %v", err) } return path } func runCLICommand(t *testing.T, command func([]string, io.Writer, io.Writer) int, args []string) (int, string, string) { t.Helper() var stdout bytes.Buffer var stderr bytes.Buffer code := command(args, &stdout, &stderr) return code, stdout.String(), stderr.String() } func promptkitV09FixtureRoot(t *testing.T) string { t.Helper() root, err := filepath.Abs(filepath.Join("..", "..", "..", "testdata", "promptkit-v0.9")) if err != nil { t.Fatalf("resolve Promptkit v0.9 fixture root: %v", err) } if _, err := os.Stat(root); err != nil { t.Fatalf("stat Promptkit v0.9 fixture root: %v", err) } return root } func writePromptkitV09Config(t *testing.T, fixtureRoot string, includeSources bool) string { t.Helper() sources := "" if includeSources { sources = fmt.Sprintf("prompt_dir: %q\nprofile_dir: %q\nschema_dir: %q\n", filepath.Join(fixtureRoot, "prompts"), filepath.Join(fixtureRoot, "profiles"), filepath.Join(fixtureRoot, "schemas")) } return writeAppConfigFile(t, sources+`backends: fixture-custom: endpoint: http://127.0.0.1:11434/v1 api_key_env: FIXTURE_BACKEND_API_KEY extra_params: backend_option: enabled: true concurrency_limit: 2 queue_capacity: 0 `) } func writePromptFile(t *testing.T, dir, id, defaultProfile string) { t.Helper() writePromptFileWithTemplate(t, dir, id, defaultProfile, "Summarize: {{input \"transcript\"}}") } func writePromptDefinition(t *testing.T, dir, name, definition string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, name), []byte(definition), 0o644); err != nil { t.Fatalf("write prompt definition: %v", err) } } func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) { t.Helper() data := fmt.Sprintf(`id: %s version: "1.0.0" default_profile: %s inputs: - name: transcript required: true messages: - role: user content: %q output: format: text validation_mode: none repair_attempts: 0 `, 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) } } func writeProfileFile(t *testing.T, dir, id, endpoint, model string) { t.Helper() data := fmt.Sprintf("id: %s\nendpoint: %s\nmodel: %s\n", id, endpoint, model) if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil { t.Fatalf("failed to write profile fixture: %v", err) } } func newTestLLMServer(content string, hitCounter *int32) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if hitCounter != nil { atomic.AddInt32(hitCounter, 1) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(fmt.Sprintf(`{"choices":[{"message":{"role":"assistant","content":%q}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, content))) })) } func writeAppConfigFile(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.yml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("failed to write app config fixture: %v", err) } return path }