diff --git a/internal/cli/run.go b/internal/cli/run.go index 72d7c3a..578b4e2 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -153,10 +153,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints") recomputeStep := singleValueFlag{name: "--recompute-step"} chunkCache := chunkCacheFlag{} - sessionID := sessionIDFlag{} + requestedSessionID := sessionIDFlag{} referenceFlags := stringListFlag{} withoutReferenceFlags := stringListFlag{} - fs.Var(&sessionID, "session-id", "prompt session identifier") + fs.Var(&requestedSessionID, "session-id", "prompt session identifier") fs.Var(&reasoningEffort, "reasoning-effort", "reasoning effort override") fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh") fs.Var(&referenceFlags, "reference", "reference binding, as slot=path, chunk.slot=path, merge.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path") @@ -199,7 +199,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i fmt.Fprintln(stderr, "notarius: --debug-dir must not be empty") return 2 } - if sessionID.set && strings.TrimSpace(sessionID.value) == "" { + if requestedSessionID.set && strings.TrimSpace(requestedSessionID.value) == "" { fmt.Fprintln(stderr, "notarius: --session-id must not be empty") return 2 } @@ -414,11 +414,15 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err)) } + effectiveSessionID, err := resolvePromptSessionID(requestedSessionID.value, effective.ResolvedPipeline.Input.Module, rawInput) + if err != nil { + return failPipelineCommand(stderr, commandState, terminalWriter, err) + } chunkPlans, err := chunkPlanStoreForRun(effective.Config.Cache.ChunkPlans, opts) if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } - checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), strings.TrimSpace(sessionID.value), runtimeOverrides, *resume) + checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), effectiveSessionID, runtimeOverrides, *resume) if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } @@ -427,7 +431,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i Prepared: prepared, Path: strings.TrimSpace(*inputPath), RawInput: rawInput, - SessionID: strings.TrimSpace(sessionID.value), + SessionID: effectiveSessionID, RunID: runID, StartedAt: startedAt, LLMProfiles: llmProfiles, diff --git a/internal/cli/run_contract_test.go b/internal/cli/run_contract_test.go index 7301729..5522907 100644 --- a/internal/cli/run_contract_test.go +++ b/internal/cli/run_contract_test.go @@ -492,17 +492,28 @@ func TestEffectiveLLMProfileIDsAreSortedDeduplicatedAndLLMOnly(t *testing.T) { } } -func TestRunSessionIDUsesExplicitValueOrSourceDocumentID(t *testing.T) { +func TestRunSessionIDUsesEffectiveValueForPromptRequests(t *testing.T) { for _, tt := range []struct { name string args []string want string }{ - {name: "source document", want: "source"}, + {name: "derived default"}, {name: "explicit trimmed value", args: []string{"--session-id", " explicit-session "}, want: "explicit-session"}, } { t.Run(tt.name, func(t *testing.T) { roots := newStateTestRoots(t) + want := tt.want + if want == "" { + rawInput, err := os.ReadFile(roots.input) + if err != nil { + t.Fatal(err) + } + want, err = resolvePromptSessionID("", "test/input", rawInput) + if err != nil { + t.Fatal(err) + } + } harness := newStateTestHarness() args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, tt.args...) var stdout, stderr bytes.Buffer @@ -517,8 +528,8 @@ func TestRunSessionIDUsesExplicitValueOrSourceDocumentID(t *testing.T) { t.Fatalf("session IDs = %#v, want all prompt-facing module requests", sessions) } for _, session := range sessions { - if session != tt.want { - t.Fatalf("session IDs = %#v, want %q", sessions, tt.want) + if session != want { + t.Fatalf("session IDs = %#v, want %q", sessions, want) } } }) diff --git a/internal/cli/session.go b/internal/cli/session.go new file mode 100644 index 0000000..2c47a04 --- /dev/null +++ b/internal/cli/session.go @@ -0,0 +1,26 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +const generatedSessionIDPrefix = "notarius:v1:" + +func resolvePromptSessionID(explicitSessionID, inputModule string, rawInput []byte) (string, error) { + inputModule = strings.TrimSpace(inputModule) + if inputModule == "" { + return "", fmt.Errorf("resolve prompt session: input module key must not be empty") + } + if sessionID := strings.TrimSpace(explicitSessionID); sessionID != "" { + return sessionID, nil + } + + hasher := sha256.New() + _, _ = hasher.Write([]byte(inputModule)) + _, _ = hasher.Write([]byte{0}) + _, _ = hasher.Write(rawInput) + return generatedSessionIDPrefix + hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/internal/cli/session_test.go b/internal/cli/session_test.go new file mode 100644 index 0000000..27866e2 --- /dev/null +++ b/internal/cli/session_test.go @@ -0,0 +1,61 @@ +package cli + +import "testing" + +func TestResolvePromptSessionIDUsesVersionedInputIdentity(t *testing.T) { + got, err := resolvePromptSessionID("", " seriatim/input/transcript ", []byte("{\"entries\":[\"one\"]}\n")) + if err != nil { + t.Fatal(err) + } + const want = "notarius:v1:e15fefdca48653e73248b4157900547be1fd34250c0138f8d3d1f2e89b43bb25" + if got != want { + t.Fatalf("resolved session = %q, want %q", got, want) + } +} + +func TestResolvePromptSessionIDStabilityAndOverride(t *testing.T) { + rawInput := []byte("same input") + baseline, err := resolvePromptSessionID("", "input/transcript", rawInput) + if err != nil { + t.Fatal(err) + } + repeated, err := resolvePromptSessionID("", "input/transcript", rawInput) + if err != nil { + t.Fatal(err) + } + if baseline != repeated { + t.Fatalf("resolved sessions = %q and %q, want stable value", baseline, repeated) + } + + differentModule, err := resolvePromptSessionID("", "input/other", rawInput) + if err != nil { + t.Fatal(err) + } + if baseline == differentModule { + t.Fatalf("resolved sessions = %q for distinct input modules", baseline) + } + + differentInput, err := resolvePromptSessionID("", "input/transcript", []byte("same inpuu")) + if err != nil { + t.Fatal(err) + } + if baseline == differentInput { + t.Fatalf("resolved sessions = %q for distinct input bytes", baseline) + } + + override, err := resolvePromptSessionID(" explicit-session ", "input/other", []byte("different input")) + if err != nil { + t.Fatal(err) + } + if override != "explicit-session" { + t.Fatalf("resolved override = %q, want %q", override, "explicit-session") + } +} + +func TestResolvePromptSessionIDRejectsEmptyInputModule(t *testing.T) { + for _, explicitSessionID := range []string{"", "explicit-session"} { + if _, err := resolvePromptSessionID(explicitSessionID, " \t", []byte("input")); err == nil { + t.Fatalf("resolvePromptSessionID(%q) error = nil, want empty module failure", explicitSessionID) + } + } +} diff --git a/internal/framework/pipeline/runner.go b/internal/framework/pipeline/runner.go index 0faead7..fded7e4 100644 --- a/internal/framework/pipeline/runner.go +++ b/internal/framework/pipeline/runner.go @@ -191,7 +191,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err return failOutput(output), fmt.Errorf("write source debug artifact: %w", err) } sourceInput := sourceInputMaterial(input.Path, input.RawInput) - sessionID := resolvedSessionID(input.SessionID, doc.ID) + sessionID := strings.TrimSpace(input.SessionID) output.Manifest.Metadata, err = manifestMetadataWithSessionID(output.Manifest.Metadata, sessionID) if err != nil { return failOutput(output), err @@ -896,13 +896,6 @@ func sourceInputOriginURI(inputPath string) string { return fileURI(inputPath) } -func resolvedSessionID(explicit string, sourceDocumentID string) string { - if trimmed := strings.TrimSpace(explicit); trimmed != "" { - return trimmed - } - return strings.TrimSpace(sourceDocumentID) -} - func manifestMetadataWithSessionID(metadata map[string]any, sessionID string) (map[string]any, error) { out, err := cloneMetadata(metadata) if err != nil { diff --git a/internal/framework/pipeline/runner_session_test.go b/internal/framework/pipeline/runner_session_test.go new file mode 100644 index 0000000..9ccff42 --- /dev/null +++ b/internal/framework/pipeline/runner_session_test.go @@ -0,0 +1,37 @@ +package pipeline + +import ( + "context" + "testing" +) + +func TestRunnerUsesProvidedSessionWithoutSourceFallback(t *testing.T) { + for _, test := range []struct { + name string + session string + wantSet bool + want string + }{ + {name: "provided", session: " supplied-session ", wantSet: true, want: "supplied-session"}, + {name: "empty", wantSet: false}, + } { + t.Run(test.name, func(t *testing.T) { + prepared, _ := preparedTerminalDebugPipeline(t) + output, err := New().Run(context.Background(), RunInput{ + Prepared: prepared, + RawInput: []byte("input"), + SessionID: test.session, + }) + if err != nil { + t.Fatal(err) + } + value, found := output.Manifest.Metadata["session_id"] + if found != test.wantSet { + t.Fatalf("manifest session presence = %t, want %t; metadata = %#v", found, test.wantSet, output.Manifest.Metadata) + } + if found && value != test.want { + t.Fatalf("manifest session = %#v, want %q", value, test.want) + } + }) + } +}