Consolidate public JSON serialization

This commit is contained in:
2026-08-11 21:40:12 +00:00
parent abeb50b525
commit c8b6d5c490
5 changed files with 389 additions and 353 deletions

View File

@@ -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.

View File

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

142
json.go
View File

@@ -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"`
}

328
json_contract_test.go Normal file
View File

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

View File

@@ -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"},