From f6ee18f6b35f78b03dacc8be44e42fc1307e95ac Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 29 Jul 2026 19:43:26 +0000 Subject: [PATCH] Add direct per-run session overrides --- convert.go | 2 + docs/roadmap/implementation.md | 2 +- engine_test.go | 25 +++- internal/domain/domain.go | 2 + internal/domain/session.go | 19 +++ internal/domain/session_test.go | 57 +++++++++ internal/llm/openai_compatible_client.go | 10 +- internal/prompt/go_renderer.go | 15 +-- internal/usecase/runner.go | 16 ++- internal/usecase/runner_test.go | 142 +++++++++++++++++++++++ json.go | 3 + public_contract_test.go | 18 ++- types.go | 18 ++- 13 files changed, 302 insertions(+), 27 deletions(-) create mode 100644 internal/domain/session.go create mode 100644 internal/domain/session_test.go diff --git a/convert.go b/convert.go index cefa123..646ac1b 100644 --- a/convert.go +++ b/convert.go @@ -16,6 +16,7 @@ func toDomainRunRequest(req RunRequest) (domain.RunRequest, error) { PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, + SessionID: req.SessionID, APIKey: req.APIKey, Inputs: toDomainArtifactRefMap(req.Inputs), Vars: copyStringMap(req.Vars), @@ -59,6 +60,7 @@ func fromDomainRunResult(result *domain.RunResult) *RunResult { PromptID: result.PromptID, PromptVersion: result.PromptVersion, PromptHash: result.PromptHash, + SessionID: result.SessionID, RenderedPromptHash: result.RenderedPromptHash, SelectedProfileID: result.SelectedProfileID, SelectedBackendID: result.SelectedBackendID, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3aceaf6..6195b1e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -369,7 +369,7 @@ resolved string. ## Stage 2 — Direct Session Resolution And Result Metadata -**Status:** Pending. +**Status:** Complete. ### Goal diff --git a/engine_test.go b/engine_test.go index 1b923c3..7245662 100644 --- a/engine_test.go +++ b/engine_test.go @@ -611,19 +611,26 @@ func TestEngineRunWithDirectorySourcesAndFileInputs(t *testing.T) { func TestRunPassesPreparedRequestToInjectedLLMClient(t *testing.T) { const directKey = "direct-injected-key" + const directSession = "assembled-session" fake := &fakeLLMClient{ response: &promptkit.GenerateResponse{Content: `{"events":[{"title":"Archive labelled"}]}`}, } engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake)) - _, err := engine.Run(context.Background(), promptkit.RunRequest{ - PromptID: frameworkStructuredEventsPromptID, - APIKey: directKey, + runRequest := promptkit.RunRequest{ + PromptID: frameworkStructuredEventsPromptID, + SessionID: " " + directSession + " ", + APIKey: directKey, Inputs: map[string]promptkit.ArtifactRef{ "transcript": promptkit.Inline("Rin opens the gate."), "glossary": promptkit.Inline("gate: A guarded passage."), }, - }) + } + prepared, err := engine.Prepare(context.Background(), runRequest) + if err != nil { + t.Fatalf("expected prepare to succeed, got %v", err) + } + result, err := engine.Run(context.Background(), runRequest) if err != nil { t.Fatalf("expected run to succeed, got %v", err) } @@ -634,6 +641,16 @@ func TestRunPassesPreparedRequestToInjectedLLMClient(t *testing.T) { if len(req.Prompt.Messages) != 2 || !strings.Contains(req.Prompt.Messages[1].Content, "Rin opens the gate.") { t.Fatalf("expected rendered prompt in generate request, got %+v", req.Prompt) } + if prepared.SessionID != directSession || + req.Prompt.SessionID != directSession || + result.SessionID != directSession { + t.Fatalf( + "direct session did not propagate consistently: prepared=%q generated=%q result=%q", + prepared.SessionID, + req.Prompt.SessionID, + result.SessionID, + ) + } if req.StructuredOutput == nil || req.StructuredOutput.Type != promptkit.StructuredOutputJSONSchema || req.StructuredOutput.JSONSchema == nil { t.Fatalf("expected structured output handoff, got %+v", req.StructuredOutput) } diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 53abe9c..7294525 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -63,6 +63,7 @@ type RunRequest struct { PromptID string PromptVersion string ProfileID string + SessionID string APIKey string `json:"-" yaml:"-"` Inputs map[string]ArtifactRef Vars map[string]string @@ -79,6 +80,7 @@ type RunResult struct { PromptID string PromptVersion string PromptHash string + SessionID string RenderedPromptHash string SelectedProfileID string SelectedBackendID string diff --git a/internal/domain/session.go b/internal/domain/session.go new file mode 100644 index 0000000..fbd4122 --- /dev/null +++ b/internal/domain/session.go @@ -0,0 +1,19 @@ +package domain + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// NormalizeSessionID applies the shared session identifier rule. +func NormalizeSessionID(raw string) (string, error) { + normalized := strings.TrimSpace(raw) + if normalized == "" { + return "", nil + } + if length := utf8.RuneCountInString(normalized); length > SessionIDMaxLength { + return "", fmt.Errorf("session_id length %d exceeds maximum %d", length, SessionIDMaxLength) + } + return normalized, nil +} diff --git a/internal/domain/session_test.go b/internal/domain/session_test.go new file mode 100644 index 0000000..2c3173f --- /dev/null +++ b/internal/domain/session_test.go @@ -0,0 +1,57 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestNormalizeSessionID(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + { + name: "trims surrounding Unicode whitespace", + raw: "\u2003 session-123 \u2003", + want: "session-123", + }, + { + name: "blank input is omitted", + raw: " \t\u2003 ", + want: "", + }, + { + name: "maximum Unicode length is accepted", + raw: strings.Repeat("界", SessionIDMaxLength), + want: strings.Repeat("界", SessionIDMaxLength), + }, + { + name: "one Unicode code point over maximum is rejected", + raw: strings.Repeat("界", SessionIDMaxLength+1), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeSessionID(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatal("expected normalization error") + } + if !strings.Contains(err.Error(), "exceeds maximum") { + t.Fatalf("expected useful length diagnostic, got %v", err) + } + return + } + if err != nil { + t.Fatalf("normalize session id: %v", err) + } + if got != tt.want { + t.Fatalf("normalized session id = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/llm/openai_compatible_client.go b/internal/llm/openai_compatible_client.go index d98ddd5..cbd3642 100644 --- a/internal/llm/openai_compatible_client.go +++ b/internal/llm/openai_compatible_client.go @@ -12,7 +12,6 @@ import ( "os" "strings" "time" - "unicode/utf8" "gitea.maximumdirect.net/eric/promptkit/internal/defaults" "gitea.maximumdirect.net/eric/promptkit/internal/domain" @@ -177,12 +176,11 @@ 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 + sessionID, err := domain.NormalizeSessionID(req.Prompt.SessionID) + if err != nil { + return openAIChatRequest{}, err } + wireReq.SessionID = sessionID wireReq.Messages = make([]openAIChatRequestMessage, 0, len(req.Prompt.Messages)) for _, msg := range req.Prompt.Messages { diff --git a/internal/prompt/go_renderer.go b/internal/prompt/go_renderer.go index 199c8c8..99345de 100644 --- a/internal/prompt/go_renderer.go +++ b/internal/prompt/go_renderer.go @@ -5,10 +5,9 @@ import ( "context" "errors" "fmt" - "gitea.maximumdirect.net/eric/promptkit/internal/domain" - "strings" "text/template" - "unicode/utf8" + + "gitea.maximumdirect.net/eric/promptkit/internal/domain" ) var ( @@ -95,10 +94,6 @@ func (r *goRenderer) Render(ctx context.Context, definition *domain.PromptDefini } 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) @@ -109,9 +104,9 @@ func renderSessionID(raw string, funcs template.FuncMap, vars map[string]string) 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) + sessionID, err := domain.NormalizeSessionID(buf.String()) + if err != nil { + return "", fmt.Errorf("%w: session_id: %v", ErrRenderFailure, err) } return sessionID, nil } diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index c863f98..b7850c7 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -160,6 +160,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes PromptID: prepared.PromptID, PromptVersion: prepared.PromptVersion, PromptHash: prepared.PromptHash, + SessionID: prepared.SessionID, RenderedPromptHash: prepared.RenderedPromptHash, SelectedProfileID: prepared.SelectedProfileID, SelectedBackendID: prepared.SelectedBackendID, @@ -178,6 +179,10 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr if strings.TrimSpace(req.PromptID) == "" { return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidRequest) } + directSessionID, err := domain.NormalizeSessionID(req.SessionID) + if err != nil { + return nil, fmt.Errorf("%w: session_id: %v", ErrInvalidRequest, err) + } start := time.Now().UTC() @@ -251,10 +256,19 @@ func (r *Runner) Prepare(ctx context.Context, req domain.RunRequest) (*domain.Pr inputHashes[name] = art.Hash } - renderedPrompt, err := r.renderer.Render(ctx, def, resolvedInputs, req.Vars) + definitionToRender := def + if directSessionID != "" { + definitionCopy := *def + definitionCopy.SessionID = "" + definitionToRender = &definitionCopy + } + renderedPrompt, err := r.renderer.Render(ctx, definitionToRender, resolvedInputs, req.Vars) if err != nil { return nil, fmt.Errorf("%w: %w", ErrPromptRender, err) } + if directSessionID != "" { + renderedPrompt.SessionID = directSessionID + } end := time.Now().UTC() return &domain.PreparedRun{ diff --git a/internal/usecase/runner_test.go b/internal/usecase/runner_test.go index 03ed1f2..db421ed 100644 --- a/internal/usecase/runner_test.go +++ b/internal/usecase/runner_test.go @@ -222,6 +222,145 @@ func TestRunnerPrepareWithExplicitProfileSelection(t *testing.T) { } } +func TestRunnerDirectSessionResolution(t *testing.T) { + t.Run("direct value wins and changes only the rendered prompt hash", func(t *testing.T) { + def := promptDef(domain.FormatText, domain.ValidationNone, 0) + def.SessionID = "template-{{.template_session}}" + promptRepo := &fakePromptRepo{def: def} + runner := NewRunner( + promptRepo, + &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, + nil, + defaultArtifactReader(), + prompt.NewGoRenderer(), + &fakeLLM{forbid: true}, + nil, + ) + req := domain.RunRequest{ + PromptID: "p", + ProfileID: "exec", + Inputs: singleInputRef(), + Vars: map[string]string{"template_session": "from-template"}, + } + + req.SessionID = " direct-one " + first, err := runner.Prepare(context.Background(), req) + if err != nil { + t.Fatalf("prepare first direct session: %v", err) + } + req.SessionID = "direct-two" + second, err := runner.Prepare(context.Background(), req) + if err != nil { + t.Fatalf("prepare second direct session: %v", err) + } + + if first.SessionID != "direct-one" || second.SessionID != "direct-two" { + t.Fatalf("direct sessions were not normalized: first=%q second=%q", first.SessionID, second.SessionID) + } + if first.PromptHash != second.PromptHash { + t.Fatalf("direct session changed prompt-definition hash: first=%q second=%q", first.PromptHash, second.PromptHash) + } + if first.RenderedPromptHash == second.RenderedPromptHash { + t.Fatal("changing direct session did not change rendered-prompt hash") + } + if def.SessionID != "template-{{.template_session}}" { + t.Fatalf("repository-owned prompt definition was mutated: %q", def.SessionID) + } + }) + + t.Run("direct value bypasses failing session template without changing messages", func(t *testing.T) { + def := promptDef(domain.FormatText, domain.ValidationNone, 0) + def.SessionID = "{{.missing_session}}" + def.Templates = []domain.PromptMessageTemplate{ + {Role: "user", Content: "Hello {{.name}}"}, + } + runner := NewRunner( + &fakePromptRepo{def: def}, + &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, + nil, + defaultArtifactReader(), + prompt.NewGoRenderer(), + &fakeLLM{forbid: true}, + nil, + ) + + prepared, err := runner.Prepare(context.Background(), domain.RunRequest{ + PromptID: "p", + ProfileID: "exec", + SessionID: "direct-session", + Inputs: singleInputRef(), + Vars: map[string]string{"name": "Rin"}, + }) + if err != nil { + t.Fatalf("prepare with direct session: %v", err) + } + if prepared.SessionID != "direct-session" { + t.Fatalf("prepared session id = %q, want direct-session", prepared.SessionID) + } + if len(prepared.Messages) != 1 || prepared.Messages[0].Content != "Hello Rin" { + t.Fatalf("message templates did not render normally: %+v", prepared.Messages) + } + }) + + t.Run("blank direct value retains prompt template behavior", func(t *testing.T) { + def := promptDef(domain.FormatText, domain.ValidationNone, 0) + def.SessionID = " template-{{.template_session}} " + runner := NewRunner( + &fakePromptRepo{def: def}, + &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, + nil, + defaultArtifactReader(), + prompt.NewGoRenderer(), + &fakeLLM{forbid: true}, + nil, + ) + + prepared, err := runner.Prepare(context.Background(), domain.RunRequest{ + PromptID: "p", + ProfileID: "exec", + SessionID: " \t ", + Inputs: singleInputRef(), + Vars: map[string]string{"template_session": "rendered"}, + }) + if err != nil { + t.Fatalf("prepare with prompt session template: %v", err) + } + if prepared.SessionID != "template-rendered" { + t.Fatalf("prepared session id = %q, want template-rendered", prepared.SessionID) + } + }) + + t.Run("overlong direct value fails before loading or generation", func(t *testing.T) { + promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)} + llmClient := &fakeLLM{resp: &domain.GenerateResponse{Content: "unexpected"}} + runner := NewRunner( + promptRepo, + &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, + nil, + defaultArtifactReader(), + defaultRenderer(), + llmClient, + nil, + ) + + _, err := runner.Run(context.Background(), domain.RunRequest{ + PromptID: "p", + ProfileID: "exec", + SessionID: strings.Repeat("界", domain.SessionIDMaxLength+1), + Inputs: singleInputRef(), + }) + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("expected ErrInvalidRequest, got %v", err) + } + if promptRepo.lastID != "" { + t.Fatalf("invalid direct session loaded prompt %q", promptRepo.lastID) + } + if llmClient.calls != 0 { + t.Fatalf("invalid direct session invoked generation %d times", llmClient.calls) + } + }) +} + func TestRunnerPrepareUsesPromptDefaultProfileWhenNoExplicitProfileID(t *testing.T) { promptRepo := &fakePromptRepo{def: promptDef(domain.FormatText, domain.ValidationNone, 0)} promptRepo.def.DefaultProfile = "from-prompt" @@ -900,6 +1039,9 @@ func TestRunnerRunSuccessful(t *testing.T) { if llmClient.lastReq.Prompt.SessionID != "session-123" { t.Fatalf("expected session id to be sent to llm, got %q", llmClient.lastReq.Prompt.SessionID) } + if res.SessionID != "session-123" { + t.Fatalf("expected session id in run result, got %q", res.SessionID) + } if res.Usage.TotalTokens != 7 { t.Fatalf("expected token usage to be retained, got %+v", res.Usage) } diff --git a/json.go b/json.go index 14673ce..d183b46 100644 --- a/json.go +++ b/json.go @@ -81,6 +81,7 @@ func (r RunResult) MarshalJSON() ([]byte, error) { PromptID: r.PromptID, PromptVersion: r.PromptVersion, PromptHash: r.PromptHash, + SessionID: r.SessionID, RenderedPromptHash: r.RenderedPromptHash, SelectedProfileID: r.SelectedProfileID, SelectedBackendID: r.SelectedBackendID, @@ -111,6 +112,7 @@ func (r *RunResult) UnmarshalJSON(data []byte) error { PromptID: wire.PromptID, PromptVersion: wire.PromptVersion, PromptHash: wire.PromptHash, + SessionID: wire.SessionID, RenderedPromptHash: wire.RenderedPromptHash, SelectedProfileID: wire.SelectedProfileID, SelectedBackendID: wire.SelectedBackendID, @@ -138,6 +140,7 @@ type runResultJSON struct { PromptID string `json:"prompt_id"` PromptVersion string `json:"prompt_version,omitempty"` PromptHash string `json:"prompt_hash,omitempty"` + SessionID string `json:"session_id,omitempty"` RenderedPromptHash string `json:"rendered_prompt_hash"` SelectedProfileID string `json:"selected_profile_id"` SelectedBackendID string `json:"selected_backend_id,omitempty"` diff --git a/public_contract_test.go b/public_contract_test.go index 260aaa7..8168c64 100644 --- a/public_contract_test.go +++ b/public_contract_test.go @@ -406,6 +406,7 @@ func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { result := promptkit.RunResult{ RunID: "opaque-run-id", Artifact: promptkit.Artifact{Name: "output", ContentType: "text/plain", Body: []byte("ok")}, + SessionID: "session-123", StartTime: start, EndTime: start.Add(1500 * time.Millisecond), Duration: 1500 * time.Millisecond, @@ -425,6 +426,9 @@ func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { if _, exists := object["duration"]; exists { t.Fatalf("unexpected nanosecond duration field in %s", payload) } + if got := object["session_id"]; got != result.SessionID { + t.Fatalf("expected session_id=%q, got %#v in %s", result.SessionID, got, payload) + } artifact, ok := object["artifact"].(map[string]any) if !ok || artifact["content_type"] != "text/plain" { t.Fatalf("expected stable artifact JSON fields, got %#v", object["artifact"]) @@ -434,7 +438,10 @@ func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatalf("unmarshal run result: %v", err) } - if decoded.Duration != result.Duration || !decoded.StartTime.Equal(result.StartTime) || !decoded.EndTime.Equal(result.EndTime) { + if decoded.SessionID != result.SessionID || + decoded.Duration != result.Duration || + !decoded.StartTime.Equal(result.StartTime) || + !decoded.EndTime.Equal(result.EndTime) { t.Fatalf("timing values did not round trip: got %#v, want %#v", decoded, result) } @@ -442,11 +449,18 @@ func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { if err != nil { t.Fatalf("marshal zero run result: %v", err) } - for _, field := range []string{"start_time", "end_time", "duration_ms"} { + for _, field := range []string{"session_id", "start_time", "end_time", "duration_ms"} { if strings.Contains(string(payload), `"`+field+`"`) { t.Fatalf("expected zero %s to be omitted, got %s", field, payload) } } + var decodedEmpty promptkit.RunResult + if err := json.Unmarshal(payload, &decodedEmpty); err != nil { + t.Fatalf("unmarshal run result without session_id: %v", err) + } + if decodedEmpty.SessionID != "" { + t.Fatalf("expected absent session_id to decode empty, got %q", decodedEmpty.SessionID) + } } func TestEngineValidationIsSinglePass(t *testing.T) { diff --git a/types.go b/types.go index 02665d6..1cde7cb 100644 --- a/types.go +++ b/types.go @@ -91,6 +91,15 @@ type RunRequest struct { // profile is used; if both are empty, the error matches ErrProfileRequired // and ErrInvalidRequest. ProfileID string + // SessionID optionally supplies a direct per-run session identifier. A + // nonblank value is trimmed and overrides the prompt definition's + // session_id template. A blank value supplies no direct override. The + // maximum is 256 Unicode code points after trimming. A direct value is + // opaque consumer metadata, not a credential, and may be exposed in + // prepared values, results, collaborator requests, provider requests, and + // provider observability. Callers should use stable, non-sensitive + // identifiers. + SessionID string // APIKey is a request-scoped direct credential. It takes precedence over // APIKeyEnv, is passed to the selected LLMClient, and is never included in // prepared values, results, hashes, JSON, String, or GoString output. @@ -140,7 +149,7 @@ type PreparedRun struct { StructuredOutput *StructuredOutputSpec `json:"structured_output,omitempty"` // InputHashes maps every supplied input name to its opaque artifact hash. InputHashes map[string]string `json:"input_hashes,omitempty"` - // SessionID is the trimmed rendered session identifier, if any. + // SessionID is the effective direct or rendered session identifier, if any. SessionID string `json:"session_id,omitempty"` // RenderedPromptHash is an opaque equality value for SessionID and Messages. RenderedPromptHash string `json:"rendered_prompt_hash"` @@ -179,6 +188,9 @@ type RunResult struct { // PromptHash is the same opaque definition equality value exposed by // PreparedRun. PromptHash string `json:"prompt_hash,omitempty"` + // SessionID is the effective direct or rendered session identifier, if any. + // JSON omits an empty value. + SessionID string `json:"session_id,omitempty"` // RenderedPromptHash is the same opaque rendered-prompt equality value // computed during preparation. RenderedPromptHash string `json:"rendered_prompt_hash"` @@ -497,8 +509,8 @@ type TokenUsage struct { // RenderedPrompt is the fully rendered prompt passed to an LLM client and has // a stable JSON representation. type RenderedPrompt struct { - // SessionID is the optional trimmed session identifier rendered from the - // prompt definition. + // SessionID is the optional effective direct or rendered session + // identifier supplied to the model client. SessionID string `json:"session_id,omitempty"` // Messages contains rendered messages in definition order. Messages []RenderedMessage `json:"messages"`