From c8b6d5c490f4d8876933ecb89198d1a19386532c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 11 Aug 2026 21:40:12 +0000 Subject: [PATCH] Consolidate public JSON serialization --- internal/domain/domain.go | 32 +-- internal/domain/prepared_run_test.go | 141 ------------ json.go | 142 ++++-------- json_contract_test.go | 328 +++++++++++++++++++++++++++ public_contract_test.go | 99 -------- 5 files changed, 389 insertions(+), 353 deletions(-) delete mode 100644 internal/domain/prepared_run_test.go create mode 100644 json_contract_test.go diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 6c11e14..61069ff 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -97,22 +97,22 @@ type RunResult struct { // PreparedRun contains pre-LLM execution state from the prepare/render phase. // It must never include resolved API key values, model output, or validation data. type PreparedRun struct { - PromptID string `json:"prompt_id"` - PromptVersion string `json:"prompt_version,omitempty"` - PromptHash string `json:"prompt_hash,omitempty"` - SelectedProfileID string `json:"selected_profile_id"` - SelectedBackendID string `json:"selected_backend_id,omitempty"` - EffectiveModelParams ExecutionTarget `json:"effective_model_params"` - TargetPresence ExecutionTargetPresence `json:"-"` - 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"` - EndTime time.Time `json:"end_time,omitempty"` - DurationMS int64 `json:"duration_ms,omitempty"` + PromptID string + PromptVersion string + PromptHash string + SelectedProfileID string + SelectedBackendID string + EffectiveModelParams ExecutionTarget + TargetPresence ExecutionTargetPresence + OutputContract OutputContract + StructuredOutput *StructuredOutputSpec + InputHashes map[string]string + SessionID string + RenderedPromptHash string + Messages []RenderedMessage + StartTime time.Time + EndTime time.Time + DurationMS int64 } // ArtifactRef represents a reference to an input artifact. diff --git a/internal/domain/prepared_run_test.go b/internal/domain/prepared_run_test.go deleted file mode 100644 index da6d231..0000000 --- a/internal/domain/prepared_run_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package domain - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestPreparedRunJSONDoesNotIncludeSecretValues(t *testing.T) { - const envName = "PROMPTKIT_TEST_API_KEY" - const secret = "super-secret-value" - t.Setenv(envName, secret) - - prepared := PreparedRun{ - PromptID: "prompt.id", - PromptVersion: "v1", - PromptHash: "prompt-hash", - SelectedProfileID: "local-fast", - EffectiveModelParams: ExecutionTarget{ - Endpoint: "http://llm/v1", - Model: "gpt-test", - APIKeyEnv: envName, - APIKey: secret, - }, - InputHashes: map[string]string{"transcript": "hash-1"}, - RenderedPromptHash: "rendered-hash", - Messages: []RenderedMessage{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Summarize this."}, - }, - } - - b, err := json.Marshal(prepared) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - - out := string(b) - if strings.Contains(out, secret) { - t.Fatalf("prepared run JSON unexpectedly contains secret value: %s", out) - } - if !strings.Contains(out, `"api_key_env":"`+envName+`"`) { - t.Fatalf("prepared run JSON should include api_key_env name: %s", out) - } - - var top map[string]any - if err := json.Unmarshal(b, &top); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - - for _, forbidden := range []string{"raw_output", "validation", "artifact"} { - if _, ok := top[forbidden]; ok { - t.Fatalf("prepared run JSON should not include %q", forbidden) - } - } -} - -func TestPreparedRunJSONIncludesMessageCacheControlOnlyWhenPresent(t *testing.T) { - prepared := PreparedRun{ - PromptID: "prompt.id", - SelectedProfileID: "local-fast", - EffectiveModelParams: ExecutionTarget{ - Endpoint: "http://llm/v1", - Model: "gpt-test", - }, - RenderedPromptHash: "rendered-hash", - Messages: []RenderedMessage{ - { - Role: "system", - Content: "You are helpful.", - CacheControl: &CacheControl{ - Type: CacheControlEphemeral, - TTL: "1h", - }, - }, - {Role: "user", Content: "Summarize this."}, - }, - } - - b, err := json.Marshal(prepared) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - - var decoded struct { - Messages []map[string]any `json:"messages"` - } - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("unmarshal failed: %v", err) - } - if len(decoded.Messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(decoded.Messages)) - } - - cacheControl, ok := decoded.Messages[0]["cache_control"].(map[string]any) - if !ok { - t.Fatalf("expected cache_control on first message, got %#v", decoded.Messages[0]) - } - if cacheControl["type"] != string(CacheControlEphemeral) || cacheControl["ttl"] != "1h" { - t.Fatalf("unexpected cache_control payload: %#v", cacheControl) - } - if _, ok := decoded.Messages[1]["cache_control"]; ok { - 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/json.go b/json.go index d183b46..f1d4727 100644 --- a/json.go +++ b/json.go @@ -2,9 +2,16 @@ package promptkit import ( "encoding/json" + "fmt" + "math" "time" ) +const ( + minDurationMilliseconds = int64(time.Duration(math.MinInt64) / time.Millisecond) + maxDurationMilliseconds = int64(time.Duration(math.MaxInt64) / time.Millisecond) +) + // MarshalJSON implements json.Marshaler for PreparedRun. It uses RFC 3339 // timestamps, integer duration_ms, and omits zero timing values. func (r PreparedRun) MarshalJSON() ([]byte, error) { @@ -21,38 +28,11 @@ func (r PreparedRun) MarshalJSON() ([]byte, error) { durationMS = &r.DurationMS } - return json.Marshal(struct { - PromptID string `json:"prompt_id"` - PromptVersion string `json:"prompt_version,omitempty"` - PromptHash string `json:"prompt_hash,omitempty"` - SelectedProfileID string `json:"selected_profile_id"` - SelectedBackendID string `json:"selected_backend_id,omitempty"` - EffectiveModelParams ExecutionTarget `json:"effective_model_params"` - 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"` - EndTime *time.Time `json:"end_time,omitempty"` - DurationMS *int64 `json:"duration_ms,omitempty"` - }{ - PromptID: r.PromptID, - PromptVersion: r.PromptVersion, - PromptHash: r.PromptHash, - SelectedProfileID: r.SelectedProfileID, - SelectedBackendID: r.SelectedBackendID, - EffectiveModelParams: r.EffectiveModelParams, - OutputContract: r.OutputContract, - StructuredOutput: r.StructuredOutput, - InputHashes: r.InputHashes, - SessionID: r.SessionID, - RenderedPromptHash: r.RenderedPromptHash, - Messages: r.Messages, - StartTime: startTime, - EndTime: endTime, - DurationMS: durationMS, + return json.Marshal(preparedRunJSON{ + preparedRunJSONFields: preparedRunJSONFields(r), + StartTime: startTime, + EndTime: endTime, + DurationMS: durationMS, }) } @@ -74,89 +54,57 @@ func (r RunResult) MarshalJSON() ([]byte, error) { } return json.Marshal(runResultJSON{ - RunID: r.RunID, - Artifact: r.Artifact, - RawOutput: r.RawOutput, - Validation: r.Validation, - PromptID: r.PromptID, - PromptVersion: r.PromptVersion, - PromptHash: r.PromptHash, - SessionID: r.SessionID, - RenderedPromptHash: r.RenderedPromptHash, - SelectedProfileID: r.SelectedProfileID, - SelectedBackendID: r.SelectedBackendID, - ModelName: r.ModelName, - Endpoint: r.Endpoint, - EffectiveModelParams: r.EffectiveModelParams, - InputHashes: r.InputHashes, - Usage: r.Usage, - StartTime: startTime, - EndTime: endTime, - DurationMS: durationMS, + runResultJSONFields: runResultJSONFields(r), + StartTime: startTime, + EndTime: endTime, + DurationMS: durationMS, }) } // UnmarshalJSON implements json.Unmarshaler for RunResult. It decodes -// duration_ms into Duration with millisecond precision. +// duration_ms into Duration with millisecond precision. A duration_ms outside +// the range representable by time.Duration returns an error without changing +// the receiver. func (r *RunResult) UnmarshalJSON(data []byte) error { var wire runResultJSON if err := json.Unmarshal(data, &wire); err != nil { return err } - *r = RunResult{ - RunID: wire.RunID, - Artifact: wire.Artifact, - RawOutput: wire.RawOutput, - Validation: wire.Validation, - PromptID: wire.PromptID, - PromptVersion: wire.PromptVersion, - PromptHash: wire.PromptHash, - SessionID: wire.SessionID, - RenderedPromptHash: wire.RenderedPromptHash, - SelectedProfileID: wire.SelectedProfileID, - SelectedBackendID: wire.SelectedBackendID, - ModelName: wire.ModelName, - Endpoint: wire.Endpoint, - EffectiveModelParams: wire.EffectiveModelParams, - InputHashes: wire.InputHashes, - Usage: wire.Usage, - Duration: time.Duration(valueOrZero(wire.DurationMS)) * time.Millisecond, + result := RunResult(wire.runResultJSONFields) + if wire.DurationMS != nil { + if *wire.DurationMS < minDurationMilliseconds || *wire.DurationMS > maxDurationMilliseconds { + return fmt.Errorf( + "decode RunResult duration_ms: %d cannot be represented as time.Duration", + *wire.DurationMS, + ) + } + result.Duration = time.Duration(*wire.DurationMS) * time.Millisecond } if wire.StartTime != nil { - r.StartTime = *wire.StartTime + result.StartTime = *wire.StartTime } if wire.EndTime != nil { - r.EndTime = *wire.EndTime + result.EndTime = *wire.EndTime } + *r = result return nil } -type runResultJSON struct { - RunID string `json:"run_id"` - Artifact Artifact `json:"artifact"` - RawOutput string `json:"raw_output"` - Validation ValidationResult `json:"validation"` - 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"` - ModelName string `json:"model_name"` - Endpoint string `json:"endpoint"` - EffectiveModelParams ExecutionTarget `json:"effective_model_params"` - InputHashes map[string]string `json:"input_hashes,omitempty"` - Usage TokenUsage `json:"usage"` - StartTime *time.Time `json:"start_time,omitempty"` - EndTime *time.Time `json:"end_time,omitempty"` - DurationMS *int64 `json:"duration_ms,omitempty"` +type preparedRunJSONFields PreparedRun + +type preparedRunJSON struct { + preparedRunJSONFields + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` } -func valueOrZero(value *int64) int64 { - if value == nil { - return 0 - } - return *value +type runResultJSONFields RunResult + +type runResultJSON struct { + runResultJSONFields + StartTime *time.Time `json:"start_time,omitempty"` + EndTime *time.Time `json:"end_time,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` } diff --git a/json_contract_test.go b/json_contract_test.go new file mode 100644 index 0000000..515de94 --- /dev/null +++ b/json_contract_test.go @@ -0,0 +1,328 @@ +package promptkit_test + +import ( + "encoding/json" + "math" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/promptkit" +) + +func TestPreparedRunJSONContractRoundTripsAllFields(t *testing.T) { + value := fullyPopulatedPreparedRun() + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal PreparedRun: %v", err) + } + + object := decodeJSONObject(t, payload) + assertJSONFields(t, object, + "prompt_id", + "prompt_version", + "prompt_hash", + "selected_profile_id", + "selected_backend_id", + "effective_model_params", + "output_contract", + "structured_output", + "input_hashes", + "session_id", + "rendered_prompt_hash", + "messages", + "start_time", + "end_time", + "duration_ms", + ) + + var messages []map[string]json.RawMessage + if err := json.Unmarshal(object["messages"], &messages); err != nil { + t.Fatalf("decode PreparedRun messages: %v", err) + } + if len(messages) != 2 { + t.Fatalf("message count = %d, want 2", len(messages)) + } + if _, ok := messages[0]["cache_control"]; !ok { + t.Fatalf("first message omitted cache_control: %s", object["messages"]) + } + if _, ok := messages[1]["cache_control"]; ok { + t.Fatalf("second message included empty cache_control: %s", object["messages"]) + } + + var decoded promptkit.PreparedRun + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal PreparedRun: %v", err) + } + if !reflect.DeepEqual(decoded, value) { + t.Fatalf("PreparedRun did not round trip:\ngot %#v\nwant %#v", decoded, value) + } + + zeroPayload, err := json.Marshal(promptkit.PreparedRun{}) + if err != nil { + t.Fatalf("marshal zero PreparedRun: %v", err) + } + zeroObject := decodeJSONObject(t, zeroPayload) + for _, field := range []string{ + "prompt_version", + "prompt_hash", + "selected_backend_id", + "structured_output", + "input_hashes", + "session_id", + "start_time", + "end_time", + "duration_ms", + } { + if _, ok := zeroObject[field]; ok { + t.Fatalf("zero PreparedRun included %q: %s", field, zeroPayload) + } + } +} + +func TestRunResultJSONContractRoundTripsAllFields(t *testing.T) { + value := fullyPopulatedRunResult() + payload, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal RunResult: %v", err) + } + + object := decodeJSONObject(t, payload) + assertJSONFields(t, object, + "run_id", + "artifact", + "raw_output", + "validation", + "prompt_id", + "prompt_version", + "prompt_hash", + "session_id", + "rendered_prompt_hash", + "selected_profile_id", + "selected_backend_id", + "model_name", + "endpoint", + "effective_model_params", + "input_hashes", + "usage", + "start_time", + "end_time", + "duration_ms", + ) + if _, ok := object["duration"]; ok { + t.Fatalf("RunResult included nanosecond duration field: %s", payload) + } + + var decoded promptkit.RunResult + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal RunResult: %v", err) + } + if !reflect.DeepEqual(decoded, value) { + t.Fatalf("RunResult did not round trip:\ngot %#v\nwant %#v", decoded, value) + } + + zeroPayload, err := json.Marshal(promptkit.RunResult{}) + if err != nil { + t.Fatalf("marshal zero RunResult: %v", err) + } + zeroObject := decodeJSONObject(t, zeroPayload) + for _, field := range []string{ + "prompt_version", + "prompt_hash", + "session_id", + "selected_backend_id", + "input_hashes", + "start_time", + "end_time", + "duration_ms", + "duration", + } { + if _, ok := zeroObject[field]; ok { + t.Fatalf("zero RunResult included %q: %s", field, zeroPayload) + } + } +} + +func TestRunResultJSONDurationMillisecondBoundaries(t *testing.T) { + maxMilliseconds := int64(time.Duration(math.MaxInt64) / time.Millisecond) + minMilliseconds := int64(time.Duration(math.MinInt64) / time.Millisecond) + + for _, milliseconds := range []int64{minMilliseconds, maxMilliseconds} { + t.Run(strconv.FormatInt(milliseconds, 10), func(t *testing.T) { + var decoded promptkit.RunResult + if err := json.Unmarshal(durationPayload(milliseconds), &decoded); err != nil { + t.Fatalf("decode representable duration_ms: %v", err) + } + want := time.Duration(milliseconds) * time.Millisecond + if decoded.Duration != want { + t.Fatalf("Duration = %v, want %v", decoded.Duration, want) + } + }) + } + + for _, milliseconds := range []int64{ + minMilliseconds - 1, + maxMilliseconds + 1, + math.MinInt64, + math.MaxInt64, + } { + t.Run(strconv.FormatInt(milliseconds, 10), func(t *testing.T) { + original := fullyPopulatedRunResult() + decoded := original + err := json.Unmarshal(durationPayload(milliseconds), &decoded) + if err == nil || !strings.Contains(err.Error(), "duration_ms") || !strings.Contains(err.Error(), "time.Duration") { + t.Fatalf("overflow error = %v", err) + } + if !reflect.DeepEqual(decoded, original) { + t.Fatalf("failed decode partially updated receiver:\ngot %#v\nwant %#v", decoded, original) + } + }) + } +} + +func fullyPopulatedPreparedRun() promptkit.PreparedRun { + start := time.Date(2026, time.August, 11, 12, 13, 14, 150_000_000, time.UTC) + return promptkit.PreparedRun{ + PromptID: "prompt.prepared", + PromptVersion: "2.1.0", + PromptHash: "prompt-hash", + SelectedProfileID: "profile-prepared", + SelectedBackendID: "backend-prepared", + EffectiveModelParams: jsonContractExecutionTarget(), + OutputContract: promptkit.OutputContract{ + Format: promptkit.FormatJSON, + ValidationMode: promptkit.ValidationJSONSchema, + SchemaPath: "schemas/prepared.json", + RepairAttempts: 2, + }, + StructuredOutput: &promptkit.StructuredOutputSpec{ + Type: promptkit.StructuredOutputJSONSchema, + JSONSchema: &promptkit.StructuredOutputJSONSpec{ + Name: "prepared_schema", + Strict: true, + Schema: map[string]any{ + "type": "object", + "required": []any{"value"}, + "properties": map[string]any{ + "value": map[string]any{"minimum": float64(1)}, + }, + }, + }, + }, + InputHashes: map[string]string{"first": "hash-1", "second": "hash-2"}, + SessionID: "session-prepared", + RenderedPromptHash: "rendered-hash", + Messages: []promptkit.RenderedMessage{ + { + Role: "system", + Content: "System message", + CacheControl: &promptkit.CacheControl{ + Type: promptkit.CacheControlEphemeral, + TTL: "1h", + }, + }, + {Role: "user", Content: "User message"}, + }, + StartTime: start, + EndTime: start.Add(1501 * time.Millisecond), + DurationMS: 1501, + } +} + +func fullyPopulatedRunResult() promptkit.RunResult { + start := time.Date(2026, time.August, 11, 15, 16, 17, 250_000_000, time.UTC) + return promptkit.RunResult{ + RunID: "run-id", + Artifact: promptkit.Artifact{ + Name: "result.json", + ContentType: "application/json", + Body: []byte(`{"value":2}`), + URI: "memory://result.json", + Size: 11, + Hash: "artifact-hash", + }, + RawOutput: `{"value":2}`, + Validation: promptkit.ValidationResult{ + Status: promptkit.ValidationFailed, + Mode: promptkit.ValidationJSONSchema, + Errors: []string{"first error", "second error"}, + SchemaPath: "schemas/result.json", + RepairAttempts: 2, + IsValid: false, + }, + PromptID: "prompt.result", + PromptVersion: "3.2.1", + PromptHash: "result-prompt-hash", + SessionID: "session-result", + RenderedPromptHash: "result-rendered-hash", + SelectedProfileID: "profile-result", + SelectedBackendID: "backend-result", + ModelName: "model-result", + Endpoint: "https://result.example/v1", + EffectiveModelParams: jsonContractExecutionTarget(), + InputHashes: map[string]string{"input": "input-hash"}, + Usage: promptkit.TokenUsage{ + PromptTokens: 101, + CompletionTokens: 202, + TotalTokens: 303, + CachedTokens: 44, + CacheWriteTokens: 55, + }, + StartTime: start, + EndTime: start.Add(1750 * time.Millisecond), + Duration: 1750 * time.Millisecond, + } +} + +func jsonContractExecutionTarget() promptkit.ExecutionTarget { + return promptkit.ExecutionTarget{ + BackendID: "backend-target", + Endpoint: "https://target.example/v1", + Model: "model-target", + Temperature: 0.75, + MaxTokens: 321, + TopP: 0.875, + TimeoutSeconds: 43, + ServiceTier: "priority", + ReasoningEffort: "high", + APIKeyEnv: "PROMPTKIT_JSON_CONTRACT_KEY", + ExtraParams: map[string]any{ + "enabled": true, + "weight": float64(1.25), + "nested": map[string]any{"name": "value"}, + }, + } +} + +func durationPayload(milliseconds int64) []byte { + return []byte(`{"duration_ms":` + strconv.FormatInt(milliseconds, 10) + `}`) +} + +func decodeJSONObject(t *testing.T, payload []byte) map[string]json.RawMessage { + t.Helper() + var object map[string]json.RawMessage + if err := json.Unmarshal(payload, &object); err != nil { + t.Fatalf("decode JSON object: %v", err) + } + return object +} + +func assertJSONFields(t *testing.T, object map[string]json.RawMessage, fields ...string) { + t.Helper() + want := make(map[string]struct{}, len(fields)) + for _, field := range fields { + want[field] = struct{}{} + } + for field := range object { + if _, ok := want[field]; !ok { + t.Errorf("unexpected JSON field %q", field) + } + } + for field := range want { + if _, ok := object[field]; !ok { + t.Errorf("missing JSON field %q", field) + } + } +} diff --git a/public_contract_test.go b/public_contract_test.go index 63e6b00..34b2d78 100644 --- a/public_contract_test.go +++ b/public_contract_test.go @@ -12,7 +12,6 @@ import ( "sync/atomic" "testing" "testing/fstest" - "time" "gitea.maximumdirect.net/eric/promptkit" ) @@ -391,18 +390,6 @@ output: } } -func TestPreparedRunJSONOmitsZeroTimingValues(t *testing.T) { - payload, err := json.Marshal(promptkit.PreparedRun{}) - if err != nil { - t.Fatalf("marshal prepared run: %v", err) - } - for _, field := range []string{"start_time", "end_time", "duration_ms"} { - if strings.Contains(string(payload), `"`+field+`"`) { - t.Fatalf("expected zero %s to be omitted, got %s", field, payload) - } - } -} - func TestBackendIdentityJSONNamesAndOmission(t *testing.T) { t.Run("execution target round trip", func(t *testing.T) { value := promptkit.ExecutionTarget{BackendID: promptkit.BackendOpenRouter} @@ -819,92 +806,6 @@ func TestBackendExtraParamsAreDeeplyCopiedAtConstructionAndLookup(t *testing.T) } } -func TestPreparedRunJSONTimingRoundTrips(t *testing.T) { - start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) - prepared := promptkit.PreparedRun{ - PromptID: "prompt", - StartTime: start, - EndTime: start.Add(1250 * time.Millisecond), - DurationMS: 1250, - } - - payload, err := json.Marshal(prepared) - if err != nil { - t.Fatalf("marshal prepared run: %v", err) - } - var decoded promptkit.PreparedRun - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal prepared run: %v", err) - } - if decoded.DurationMS != prepared.DurationMS || - !decoded.StartTime.Equal(prepared.StartTime) || - !decoded.EndTime.Equal(prepared.EndTime) { - t.Fatalf("timing values did not round trip: got %#v, want %#v", decoded, prepared) - } -} - -func TestRunResultJSONUsesMillisecondsAndRoundTrips(t *testing.T) { - start := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC) - 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, - } - - payload, err := json.Marshal(result) - if err != nil { - t.Fatalf("marshal run result: %v", err) - } - var object map[string]any - if err := json.Unmarshal(payload, &object); err != nil { - t.Fatalf("decode run result JSON: %v", err) - } - if got := object["duration_ms"]; got != float64(1500) { - t.Fatalf("expected duration_ms=1500, got %#v in %s", got, payload) - } - 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"]) - } - - var decoded promptkit.RunResult - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal run result: %v", err) - } - 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) - } - - payload, err = json.Marshal(promptkit.RunResult{}) - if err != nil { - t.Fatalf("marshal zero run result: %v", err) - } - 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) { client := &fakeLLMClient{ response: &promptkit.GenerateResponse{Content: "not-json"},