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) } } }