From 63fb8fc1329751c7f3a173181602301ebcea71aa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 2 Jul 2026 20:08:44 -0500 Subject: [PATCH] Implement support for OpenRouter sticky routing via a session_id variable --- docs/cli.md | 1 + docs/config.md | 11 ++- docs/integrations/openai-compatible-chat.md | 5 +- docs/internal/adapters.md | 4 +- docs/roadmap/cache.md | 1 - docs/roadmap/implementation.md | 4 +- internal/domain/domain.go | 10 +- internal/domain/prepared_run_test.go | 36 ++++++++ internal/format/prepared_run.go | 3 + internal/format/prepared_run_test.go | 17 ++++ internal/llm/openai_compatible_client.go | 8 ++ internal/llm/openai_compatible_client_test.go | 92 ++++++++++++++++++- internal/prompt/go_renderer.go | 32 ++++++- internal/prompt/renderer_test.go | 73 +++++++++++++++ internal/promptdef/filesystem_repository.go | 2 + internal/promptdef/repository_test.go | 10 ++ .../promptdef/testdata/valid_session_id.yaml | 10 ++ internal/usecase/runner.go | 8 +- internal/usecase/runner_test.go | 49 +++++++++- 19 files changed, 363 insertions(+), 13 deletions(-) create mode 100644 internal/promptdef/testdata/valid_session_id.yaml diff --git a/docs/cli.md b/docs/cli.md index 8c72e6d..446f140 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -83,6 +83,7 @@ Notes: - `--input name=path` maps prompt input names to local file paths. - `--var name=value` maps template variable names to values. +- If a prompt defines `session_id: "{{ .session_id }}"`, pass the OpenRouter sticky-routing value with `--var session_id=`. - Both flags can be repeated. - Both flags also support comma-separated batches, for example: - `--input transcript=./t.md,glossary=./g.yml` diff --git a/docs/config.md b/docs/config.md index a40705f..39e0cc8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -104,6 +104,7 @@ Field reference: - `version` (required): prompt version. - `default_profile` (optional): profile ID used when request does not provide `profile_id`. - `description` (optional): prompt description. +- `session_id` (optional): Go-template string for OpenRouter sticky-routing `session_id`; rendered from request vars. - `inputs` (optional list): expected named inputs. - `messages` (required list): prompt message templates. - `output` (required object): output contract. @@ -150,6 +151,14 @@ messages: Use cache control on stable reusable prompt content. Dynamic per-run inputs before the cache-controlled message change the provider cache key. +Example prompt-level session ID: + +```yaml +session_id: "{{ .session_id }}" +``` + +When configured, `session_id` is rendered with the same variable context as messages. The rendered value is trimmed, omitted when empty, and rejected if longer than 256 characters. CLI callers pass the value through `--var session_id=`; HTTP callers pass it through `"vars": {"session_id": ""}`. + `output` fields: - `format` (required): `text`, `markdown`, or `json`. @@ -205,7 +214,7 @@ Profile rules: Current outbound request behavior: -- The OpenAI-compatible client currently serializes: `model`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, and optional `response_format` for `json_schema` prompts. +- The OpenAI-compatible client currently serializes: `model`, optional `session_id`, `messages`, `temperature`, `max_tokens`, `top_p`, `service_tier`, and optional `response_format` for `json_schema` prompts. - Messages without `cache_control` serialize with string `content`. - Messages with `cache_control` serialize as a single text content-block array containing `cache_control`. - `reasoning_effort` and `extra_params` are parsed and carried in effective settings, but are not currently serialized into outbound chat-completions requests. diff --git a/docs/integrations/openai-compatible-chat.md b/docs/integrations/openai-compatible-chat.md index 5e3cb0a..4a96668 100644 --- a/docs/integrations/openai-compatible-chat.md +++ b/docs/integrations/openai-compatible-chat.md @@ -26,6 +26,7 @@ Example: Serialized JSON fields: - `model` (required after fallback resolution) +- `session_id` (only when the rendered prompt includes a non-empty session ID) - `messages` (rendered prompt messages) - `temperature` (only when non-zero) - `max_tokens` (only when non-zero) @@ -35,6 +36,8 @@ Serialized JSON fields: `service_tier` is provider-specific. OpenRouter currently documents request values such as `flex` and `priority`; Scriptorium forwards any non-empty configured value and lets the backend validate support. +`session_id` is rendered from prompt YAML using request variables and serialized as a top-level JSON request field. Scriptorium does not send an `x-session-id` header. Empty rendered session IDs are omitted, and values longer than 256 characters are rejected before the HTTP request. + Messages without prompt cache control serialize with string `content`: ```json @@ -140,7 +143,7 @@ The following fields may exist in profile/effective settings but are not current - `reasoning_effort` - `extra_params` -The client also does not serialize top-level `cache_control` or `session_id`. +The client also does not serialize top-level `cache_control`. No built-in retries, tool-calls, or multi-request payload modes are implemented in this client. diff --git a/docs/internal/adapters.md b/docs/internal/adapters.md index a993be7..4794749 100644 --- a/docs/internal/adapters.md +++ b/docs/internal/adapters.md @@ -146,5 +146,5 @@ Behavior highlights: - Adapter packages do not own runner decision logic. - External request/response strictness is part of contract stability. - Prepared-render output never includes resolved API key values. -- Outbound OpenAI-compatible request includes only currently serialized fields (`model`, `messages`, optional `temperature`, `max_tokens`, `top_p`, optional `service_tier`, optional `response_format`). -- Outbound cache control is message-level only; no top-level cache-control/session fields are serialized. +- Outbound OpenAI-compatible request includes only currently serialized fields (`model`, optional `session_id`, `messages`, optional `temperature`, `max_tokens`, `top_p`, optional `service_tier`, optional `response_format`). +- Outbound cache control is message-level only; no top-level cache-control field is serialized. diff --git a/docs/roadmap/cache.md b/docs/roadmap/cache.md index a5846d6..ce7a0dc 100644 --- a/docs/roadmap/cache.md +++ b/docs/roadmap/cache.md @@ -129,7 +129,6 @@ These are intentionally out of scope for the initial feature: - Image, tool, or non-text content blocks. - Provider-specific automatic prompt caching toggles. - Top-level OpenRouter `cache_control`. -- Top-level OpenRouter `session_id`. - General-purpose serialization of `extra_params`. - Provider-specific validation profiles for cache-control limits. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 48cae9b..3131d0a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -4,7 +4,7 @@ This plan implements the feature defined in `docs/roadmap/cache.md`. -The implementation target is message-level Anthropic/OpenRouter-style cache control for rendered prompt messages, plus cache usage observability. Do not implement deferred roadmap items such as `content_blocks`, top-level `cache_control`, `session_id`, or general `extra_params` serialization. +The implementation target is message-level Anthropic/OpenRouter-style cache control for rendered prompt messages, plus cache usage observability. Do not implement deferred roadmap items such as `content_blocks`, top-level `cache_control`, or general `extra_params` serialization. Follow the policy documents under `docs/policy/` while implementing: @@ -182,7 +182,7 @@ Request serialization decisions: - Keep string content for messages without cache control. - Use content blocks only when `RenderedMessage.CacheControl` is non-nil. - Do not introduce `content_blocks` prompt syntax. -- Do not serialize top-level `cache_control`, top-level `session_id`, or `extra_params`. +- Do not serialize top-level `cache_control` or `extra_params`. - Split request and response wire message structs. The current `openAIChatMessage` is used in both request and response paths; changing request content to `any` should not make response decoding less strict than necessary. Use request wire types equivalent to: diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 694f29d..38f770f 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -47,6 +47,11 @@ const ( CacheControlEphemeral CacheControlType = "ephemeral" ) +const ( + // SessionIDMaxLength is OpenRouter's documented maximum session_id length. + SessionIDMaxLength = 256 +) + // CacheControl describes provider cache metadata attached to prompt content. type CacheControl struct { Type CacheControlType `yaml:"type" json:"type"` @@ -98,6 +103,7 @@ type PreparedRun struct { OutputContract OutputContract `json:"output_contract"` StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` InputHashes map[string]string `json:"input_hashes,omitempty"` + SessionID string `json:"session_id,omitempty"` RenderedPromptHash string `json:"rendered_prompt_hash"` Messages []RenderedMessage `json:"messages"` StartTime time.Time `json:"start_time,omitempty"` @@ -128,6 +134,7 @@ type PromptDefinition struct { Version string `yaml:"version"` DefaultProfile string `yaml:"default_profile"` Description string `yaml:"description"` + SessionID string `yaml:"session_id" json:"session_id,omitempty"` Inputs []PromptInput `yaml:"inputs"` Templates []PromptMessageTemplate `yaml:"templates"` OutputFormat OutputFormat `yaml:"output_format"` @@ -189,7 +196,8 @@ type OutputContract struct { // RenderedPrompt represents the prompt after template application. type RenderedPrompt struct { - Messages []RenderedMessage `json:"messages"` + SessionID string `json:"session_id,omitempty"` + Messages []RenderedMessage `json:"messages"` } // RenderedMessage is a single message in a rendered prompt. diff --git a/internal/domain/prepared_run_test.go b/internal/domain/prepared_run_test.go index 9fe1923..48015e0 100644 --- a/internal/domain/prepared_run_test.go +++ b/internal/domain/prepared_run_test.go @@ -102,3 +102,39 @@ func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) t.Fatalf("expected second message to omit cache_control, got %#v", decoded.Messages[1]) } } + +func TestPreparedRunJSONIncludesSessionIDOnlyWhenPresent(t *testing.T) { + prepared := PreparedRun{ + PromptID: "prompt.id", + SelectedProfileID: "local-fast", + EffectiveModelParams: ExecutionTarget{ + Endpoint: "http://llm/v1", + Model: "gpt-test", + }, + SessionID: "session-123", + RenderedPromptHash: "rendered-hash", + Messages: []RenderedMessage{{Role: "user", Content: "Summarize this."}}, + } + + b, err := json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]any + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if decoded["session_id"] != "session-123" { + t.Fatalf("expected session_id in prepared run JSON, got %#v", decoded["session_id"]) + } + + prepared.SessionID = "" + b, err = json.Marshal(prepared) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(b), "session_id") { + t.Fatalf("expected empty session_id to be omitted, got %s", b) + } +} diff --git a/internal/format/prepared_run.go b/internal/format/prepared_run.go index 690af67..5610180 100644 --- a/internal/format/prepared_run.go +++ b/internal/format/prepared_run.go @@ -96,6 +96,9 @@ func (textPreparedRunFormatter) Format(prepared *domain.PreparedRun) ([]byte, er if prepared.PromptHash != "" { fmt.Fprintf(&b, "prompt_hash: %s\n", prepared.PromptHash) } + if prepared.SessionID != "" { + fmt.Fprintf(&b, "session_id: %s\n", prepared.SessionID) + } fmt.Fprintf(&b, "rendered_prompt_hash: %s\n", prepared.RenderedPromptHash) target := prepared.EffectiveModelParams diff --git a/internal/format/prepared_run_test.go b/internal/format/prepared_run_test.go index 4527062..31f78e7 100644 --- a/internal/format/prepared_run_test.go +++ b/internal/format/prepared_run_test.go @@ -89,6 +89,19 @@ func TestTextFormatterIncludesMessageCacheControlBeforeContent(t *testing.T) { } } +func TestTextFormatterIncludesSessionIDWhenPresent(t *testing.T) { + prepared := samplePreparedRun() + prepared.SessionID = "session-123" + + out, err := FormatPreparedRun(prepared, PreparedRunFormatText) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !strings.Contains(string(out), "session_id: session-123\n") { + t.Fatalf("expected session_id in text output, got:\n%s", out) + } +} + func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) { prepared := samplePreparedRun() prepared.Messages = []domain.RenderedMessage{ @@ -116,6 +129,7 @@ func TestTextFormatterOmitsEmptyCacheControlTTL(t *testing.T) { func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) { prepared := samplePreparedRun() + prepared.SessionID = "session-123" out, err := FormatPreparedRun(prepared, PreparedRunFormatJSON) if err != nil { @@ -139,6 +153,9 @@ func TestJSONFormatterEmitsValidJSONAndIncludesPreparedRunFields(t *testing.T) { if decoded["rendered_prompt_hash"] != "rendered-hash" { t.Fatalf("expected rendered_prompt_hash in json output, got %#v", decoded["rendered_prompt_hash"]) } + if decoded["session_id"] != "session-123" { + t.Fatalf("expected session_id in json output, got %#v", decoded["session_id"]) + } if _, ok := decoded["effective_model_params"]; !ok { t.Fatalf("expected effective_model_params in json output, got %#v", decoded) } diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index a1a7790..705e899 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -12,6 +12,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" @@ -169,6 +170,12 @@ func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultMod wireReq := openAIChatRequest{ Model: model, } + if sessionID := strings.TrimSpace(req.Prompt.SessionID); sessionID != "" { + if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength { + return openAIChatRequest{}, fmt.Errorf("session_id length %d exceeds maximum %d", n, domain.SessionIDMaxLength) + } + wireReq.SessionID = sessionID + } wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages)) for _, msg := range req.Prompt.Messages { @@ -200,6 +207,7 @@ func openAIChatRequestFromGenerateRequest(req domain.GenerateRequest, defaultMod type openAIChatRequest struct { Model string `json:"model"` + SessionID string `json:"session_id,omitempty"` Messages []openAIChatRequestMessage `json:"messages"` Temperature *float64 `json:"temperature,omitempty"` MaxTokens *int `json:"max_tokens,omitempty"` diff --git a/internal/llm/openai_compatible_client_test.go b/internal/llm/openai_compatible_client_test.go index f7371f2..28b3c19 100644 --- a/internal/llm/openai_compatible_client_test.go +++ b/internal/llm/openai_compatible_client_test.go @@ -181,7 +181,7 @@ func TestOpenAICompatibleClientSerializesCacheControlledMessageAsContentBlock(t t.Fatalf("expected no error, got %v", err) } - for _, forbidden := range []string{"cache_control", "session_id", "extra_params"} { + for _, forbidden := range []string{"cache_control", "extra_params"} { if _, exists := observedBody[forbidden]; exists { t.Fatalf("expected top-level %s to be omitted, got %#v", forbidden, observedBody[forbidden]) } @@ -262,6 +262,96 @@ func TestOpenAICompatibleClientOmitsEmptyCacheControlTTL(t *testing.T) { } } +func TestOpenAICompatibleClientSerializesSessionID(t *testing.T) { + var observedBody map[string]any + var observedSessionHeader string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + observedSessionHeader = r.Header.Get("x-session-id") + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer ts.Close() + + client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"}) + if err != nil { + t.Fatal(err) + } + + _, err = client.Generate(context.Background(), domain.GenerateRequest{ + Prompt: domain.RenderedPrompt{ + SessionID: " session-123 ", + Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}, + }, + Target: domain.ExecutionTarget{Model: "model"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if observedBody["session_id"] != "session-123" { + t.Fatalf("expected top-level session_id, got %#v", observedBody["session_id"]) + } + if observedSessionHeader != "" { + t.Fatalf("did not expect x-session-id header, got %q", observedSessionHeader) + } +} + +func TestOpenAICompatibleClientOmitsEmptySessionID(t *testing.T) { + var observedBody map[string]any + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer ts.Close() + + client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"}) + if err != nil { + t.Fatal(err) + } + + _, err = client.Generate(context.Background(), domain.GenerateRequest{ + Prompt: domain.RenderedPrompt{ + SessionID: " ", + Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}, + }, + Target: domain.ExecutionTarget{Model: "model"}, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if _, exists := observedBody["session_id"]; exists { + t.Fatalf("expected empty session_id to be omitted, got %#v", observedBody["session_id"]) + } +} + +func TestOpenAICompatibleClientRejectsTooLongSessionID(t *testing.T) { + client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{ + BaseURL: "http://example.com/v1", + Model: "model", + }) + if err != nil { + t.Fatal(err) + } + + _, err = client.Generate(context.Background(), domain.GenerateRequest{ + Prompt: domain.RenderedPrompt{ + SessionID: strings.Repeat("x", domain.SessionIDMaxLength+1), + Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}, + }, + }) + if err == nil { + t.Fatal("expected invalid request error") + } + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest, got %v", err) + } +} + func TestOpenAICompatibleClientParsesCacheUsage(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{ diff --git a/internal/prompt/go_renderer.go b/internal/prompt/go_renderer.go index dc317fc..eecc2c7 100644 --- a/internal/prompt/go_renderer.go +++ b/internal/prompt/go_renderer.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "strings" "text/template" + "unicode/utf8" ) var ( @@ -50,6 +52,11 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini }, } + sessionID, err := renderSessionID(definition.SessionID, funcs, vars) + if err != nil { + return nil, err + } + var renderedMessages []domain.RenderedMessage for i, tmplMsg := range definition.Templates { @@ -82,10 +89,33 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini } return &domain.RenderedPrompt{ - Messages: renderedMessages, + SessionID: sessionID, + Messages: renderedMessages, }, nil } +func renderSessionID(raw string, funcs template.FuncMap, vars map[string]string) (string, error) { + if strings.TrimSpace(raw) == "" { + return "", nil + } + + tmpl, err := template.New("session_id").Funcs(funcs).Option("missingkey=error").Parse(raw) + if err != nil { + return "", fmt.Errorf("%w: session_id: %v", ErrInvalidTemplate, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, vars); err != nil { + return "", fmt.Errorf("%w: session_id: %w", ErrRenderFailure, err) + } + + sessionID := strings.TrimSpace(buf.String()) + if n := utf8.RuneCountInString(sessionID); n > domain.SessionIDMaxLength { + return "", fmt.Errorf("%w: session_id length %d exceeds maximum %d", ErrRenderFailure, n, domain.SessionIDMaxLength) + } + return sessionID, nil +} + func cloneCacheControl(in *domain.CacheControl) *domain.CacheControl { if in == nil { return nil diff --git a/internal/prompt/renderer_test.go b/internal/prompt/renderer_test.go index 5b360a5..a2dee8a 100644 --- a/internal/prompt/renderer_test.go +++ b/internal/prompt/renderer_test.go @@ -3,6 +3,7 @@ package prompt import ( "context" "errors" + "strings" "testing" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" @@ -155,6 +156,78 @@ func TestGoRenderer_Render(t *testing.T) { } }) + t.Run("rendering session id from vars", func(t *testing.T) { + def := &domain.PromptDefinition{ + SessionID: " {{ .session_id }} ", + Inputs: []domain.PromptInput{{Name: "transcript", Required: true}}, + Templates: []domain.PromptMessageTemplate{ + {Role: "system", Content: "Speak in a {{.tone}} tone."}, + }, + } + + res, err := renderer.Render(ctx, def, inputs, map[string]string{ + "tone": "concise", + "session_id": "agent-session-123", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.SessionID != "agent-session-123" { + t.Fatalf("unexpected session id: %q", res.SessionID) + } + }) + + t.Run("empty rendered session id is omitted", func(t *testing.T) { + def := &domain.PromptDefinition{ + SessionID: " ", + Inputs: []domain.PromptInput{{Name: "transcript", Required: true}}, + Templates: []domain.PromptMessageTemplate{ + {Role: "system", Content: "Speak in a {{.tone}} tone."}, + }, + } + + res, err := renderer.Render(ctx, def, inputs, vars) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.SessionID != "" { + t.Fatalf("expected empty session id, got %q", res.SessionID) + } + }) + + t.Run("missing session id var fails rendering", func(t *testing.T) { + def := &domain.PromptDefinition{ + SessionID: "{{ .session_id }}", + Inputs: []domain.PromptInput{{Name: "transcript", Required: true}}, + Templates: []domain.PromptMessageTemplate{ + {Role: "system", Content: "Speak in a {{.tone}} tone."}, + }, + } + + _, err := renderer.Render(ctx, def, inputs, vars) + if !errors.Is(err, ErrRenderFailure) { + t.Fatalf("expected ErrRenderFailure, got %v", err) + } + }) + + t.Run("too long rendered session id fails rendering", func(t *testing.T) { + def := &domain.PromptDefinition{ + SessionID: "{{ .session_id }}", + Inputs: []domain.PromptInput{{Name: "transcript", Required: true}}, + Templates: []domain.PromptMessageTemplate{ + {Role: "system", Content: "Speak in a {{.tone}} tone."}, + }, + } + + _, err := renderer.Render(ctx, def, inputs, map[string]string{ + "tone": "concise", + "session_id": strings.Repeat("x", domain.SessionIDMaxLength+1), + }) + if !errors.Is(err, ErrRenderFailure) { + t.Fatalf("expected ErrRenderFailure, got %v", err) + } + }) + t.Run("inserting required input artifact", func(t *testing.T) { def := &domain.PromptDefinition{ Inputs: []domain.PromptInput{{Name: "transcript", Required: true}}, diff --git a/internal/promptdef/filesystem_repository.go b/internal/promptdef/filesystem_repository.go index 865b73f..737f90d 100644 --- a/internal/promptdef/filesystem_repository.go +++ b/internal/promptdef/filesystem_repository.go @@ -29,6 +29,7 @@ type promptDefinitionFile struct { Version string `yaml:"version"` DefaultProfile *string `yaml:"default_profile"` Description string `yaml:"description"` + SessionID string `yaml:"session_id"` Inputs []promptInputFile `yaml:"inputs"` Messages []promptMessageFile `yaml:"messages"` Output promptOutputContractFile `yaml:"output"` @@ -274,6 +275,7 @@ func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*d Version: version, DefaultProfile: defaultProfile, Description: strings.TrimSpace(raw.Description), + SessionID: strings.TrimSpace(raw.SessionID), Inputs: inputs, Templates: templates, OutputFormat: raw.Output.Format, diff --git a/internal/promptdef/repository_test.go b/internal/promptdef/repository_test.go index 4db41c9..b5899d7 100644 --- a/internal/promptdef/repository_test.go +++ b/internal/promptdef/repository_test.go @@ -96,6 +96,16 @@ func TestFilesystemRepository_GetPromptDefinition(t *testing.T) { } }) + t.Run("valid session id template", func(t *testing.T) { + p, err := repo.GetPromptDefinition(ctx, "valid-session-id", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.SessionID != "{{ .session_id }}" { + t.Fatalf("expected trimmed session_id template, got %q", p.SessionID) + } + }) + t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) { nestedDir := filepath.Join(tmpDir, "dnd", "recap") if err := os.MkdirAll(nestedDir, 0o755); err != nil { diff --git a/internal/promptdef/testdata/valid_session_id.yaml b/internal/promptdef/testdata/valid_session_id.yaml new file mode 100644 index 0000000..b477662 --- /dev/null +++ b/internal/promptdef/testdata/valid_session_id.yaml @@ -0,0 +1,10 @@ +id: valid-session-id +version: "1.0.0" +session_id: " {{ .session_id }} " +messages: + - role: user + content: Hello. +output: + format: markdown + validation_mode: basic + repair_attempts: 0 diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 508acb2..42c3c96 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -90,7 +90,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes } genResp, err := r.llm.Generate(ctx, domain.GenerateRequest{ - Prompt: domain.RenderedPrompt{Messages: prepared.Messages}, + Prompt: domain.RenderedPrompt{SessionID: prepared.SessionID, Messages: prepared.Messages}, Target: prepared.EffectiveModelParams, StructuredOutput: prepared.StructuredOutput, }) @@ -233,6 +233,7 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr OutputContract: effectiveContract, StructuredOutput: structuredOutput, InputHashes: inputHashes, + SessionID: renderedPrompt.SessionID, RenderedPromptHash: hashRenderedPrompt(*renderedPrompt), Messages: renderedPrompt.Messages, StartTime: start, @@ -424,6 +425,11 @@ func resolveOutputContract(def *domain.PromptDefinition, override *domain.Output func hashRenderedPrompt(p domain.RenderedPrompt) string { var b strings.Builder + if p.SessionID != "" { + b.WriteString("session_id=") + b.WriteString(p.SessionID) + b.WriteString("\n---\n") + } for _, msg := range p.Messages { b.WriteString(msg.Role) b.WriteByte('\n') diff --git a/internal/usecase/runner_test.go b/internal/usecase/runner_test.go index c9d6447..d5fad34 100644 --- a/internal/usecase/runner_test.go +++ b/internal/usecase/runner_test.go @@ -160,7 +160,7 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) { "a://t": {Body: []byte("transcript"), Hash: hashString("transcript")}, "a://g": {Body: []byte("glossary"), Hash: hashString("glossary")}, }} - renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}} + renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}} llmClient := &fakeLLM{forbid: true} runner := NewRunner(promptRepo, execRepo, reader, renderer, llmClient, nil) @@ -198,6 +198,9 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) { if len(prepared.Messages) != 2 { t.Fatalf("expected two messages, got %d", len(prepared.Messages)) } + if prepared.SessionID != "session-123" { + t.Fatalf("expected prepared session id, got %q", prepared.SessionID) + } if llmClient.calls != 0 { t.Fatalf("prepare should not call llm, calls=%d", llmClient.calls) } @@ -632,6 +635,45 @@ func TestHashRenderedPromptIncludesCacheControlWhenPresent(t *testing.T) { } } +func TestHashRenderedPromptIncludesSessionIDWhenPresent(t *testing.T) { + withoutSession := domain.RenderedPrompt{Messages: []domain.RenderedMessage{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "usr"}, + }} + withSession := domain.RenderedPrompt{ + SessionID: "session-123", + Messages: []domain.RenderedMessage{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "usr"}, + }, + } + alsoWithSession := domain.RenderedPrompt{ + SessionID: "session-123", + Messages: []domain.RenderedMessage{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "usr"}, + }, + } + otherSession := domain.RenderedPrompt{ + SessionID: "session-456", + Messages: []domain.RenderedMessage{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "usr"}, + }, + } + + sessionHash := hashRenderedPrompt(withSession) + if sessionHash == hashRenderedPrompt(withoutSession) { + t.Fatal("expected session_id to change rendered prompt hash") + } + if sessionHash != hashRenderedPrompt(alsoWithSession) { + t.Fatal("expected identical session_id to produce stable hash") + } + if sessionHash == hashRenderedPrompt(otherSession) { + t.Fatal("expected session_id value changes to affect rendered prompt hash") + } +} + func TestRunnerRunSuccessful(t *testing.T) { promptRepo := &fakePromptRepo{def: promptDef(domain.FormatMarkdown, domain.ValidationBasic, 0)} execRepo := &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}} @@ -639,7 +681,7 @@ func TestRunnerRunSuccessful(t *testing.T) { "a://t": {Body: []byte("transcript"), Hash: hashString("transcript")}, "a://g": {Body: []byte("glossary"), Hash: hashString("glossary")}, }} - renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}} + renderer := &fakeRenderer{rendered: &domain.RenderedPrompt{SessionID: "session-123", Messages: []domain.RenderedMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "usr"}}}} llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "# recap", Usage: domain.TokenUsage{TotalTokens: 7}}} runner := NewRunner(promptRepo, execRepo, reader, renderer, llmClient, nil) @@ -686,6 +728,9 @@ func TestRunnerRunSuccessful(t *testing.T) { if llmClient.lastReq.Target.TimeoutSeconds != 90 { t.Fatalf("expected timeout propagation, got %d", llmClient.lastReq.Target.TimeoutSeconds) } + if llmClient.lastReq.Prompt.SessionID != "session-123" { + t.Fatalf("expected session id to be sent to llm, got %q", llmClient.lastReq.Prompt.SessionID) + } } func TestRunnerRunAndPrepareResolveSameProfileAndEffectiveSettings(t *testing.T) {