From 1d8d5635e12be2d7d78f15393bab948aaeb0d042 Mon Sep 17 00:00:00 2001 From: MMinasyan Date: Mon, 2 Nov 2026 20:55:26 +0400 Subject: [PATCH 1/2] fix(harness): preserve one accepted request representation --- harness/codec.go | 63 +++++--- harness/codec_test.go | 208 +++++++++++++++++++++---- harness/compact.go | 17 +- harness/composition_test.go | 95 +++++++++-- harness/context.go | 119 +++++--------- harness/effect.go | 78 +++------- harness/effect_test.go | 18 ++- harness/graph.go | 24 +++ harness/graph_test.go | 125 +++++++++++++++ harness/hooks_public_test.go | 20 +-- harness/hooks_test.go | 32 ++-- harness/payload.go | 112 ++++++++----- harness/public_test.go | 139 +++++++++++------ harness/schema.go | 11 +- harness/snapshot_public_test.go | 3 +- harness/snapshot_test.go | 1 + harness/strategy_contract_test.go | 93 +++++++++++ internal/storage/sqlite.go | 7 +- internal/storage/sqlite_test.go | 40 ++--- runtime/background_lifecycle_test.go | 2 +- runtime/compact_lifecycle_test.go | 8 +- runtime/composed_tools_harness_test.go | 2 +- runtime/preparation_test.go | 16 +- runtime/production_composition_test.go | 2 +- runtime/protocol_paging_test.go | 2 +- runtime/tools_composed_test.go | 2 +- 26 files changed, 865 insertions(+), 374 deletions(-) diff --git a/harness/codec.go b/harness/codec.go index 231d7978..c724c424 100644 --- a/harness/codec.go +++ b/harness/codec.go @@ -2,6 +2,7 @@ package harness import ( "bytes" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -138,8 +139,7 @@ func arrayMember(obj map[string]json.RawMessage, key string, required bool) ([]j return items, nil } -// extraMember decodes one opaque model.Extra member. Extra contents stay raw -// JSON preserved verbatim: no Harness key rules apply inside them. +// extraMember decodes framed opaque keys and JSON values. func extraMember(obj map[string]json.RawMessage, key string, required bool) (model.Extra, error) { raw, err := member(obj, key, required) if err != nil || raw == nil { @@ -150,26 +150,50 @@ func extraMember(obj map[string]json.RawMessage, key string, required bool) (mod return nil, fmt.Errorf("member %q: %w", key, err) } extra := make(model.Extra, len(inner)) - for k, v := range inner { - if !json.Valid(v) { - return nil, fmt.Errorf("member %q: extra field %q is not valid JSON", key, k) + for k := range inner { + decodedKey, err := decodeFrame(k) + if err != nil { + return nil, fmt.Errorf("member %q: invalid extra key: %w", key, err) } - extra[k] = model.CloneRaw(v) + v, err := framedMember(inner, k, true) + if err != nil || !json.Valid(v) { + return nil, fmt.Errorf("member %q: invalid extra value for %q", key, k) + } + extra[string(decodedKey)] = v } return extra, nil } -// rawJSONMember decodes one opaque raw-JSON member, returning an owned copy -// of the exact stored bytes. -func rawJSONMember(obj map[string]json.RawMessage, key string, required bool) (json.RawMessage, error) { +// decodeFrame accepts only canonical standard base64. +func decodeFrame(s string) ([]byte, error) { + b, err := base64.StdEncoding.Strict().DecodeString(s) + if err != nil || base64.StdEncoding.EncodeToString(b) != s { + return nil, fmt.Errorf("noncanonical base64") + } + return b, nil +} + +func framedMember(obj map[string]json.RawMessage, key string, required bool) (json.RawMessage, error) { raw, err := member(obj, key, required) if err != nil || raw == nil { return nil, err } - if !json.Valid(raw) { - return nil, fmt.Errorf("member %q is not valid JSON", key) + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("member %q must be a base64 string: %w", key, err) + } + b, err := decodeFrame(s) + if err != nil { + return nil, fmt.Errorf("member %q: %w", key, err) + } + if len(b) == 0 { + return nil, fmt.Errorf("member %q contains empty bytes", key) } - return model.CloneRaw(raw), nil + return json.RawMessage(b), nil +} + +func frame(raw []byte) json.RawMessage { + return mustMarshal(base64.StdEncoding.EncodeToString(raw)) } // encodeTime renders one durable timestamp: nonzero, normalized to UTC in the @@ -387,13 +411,16 @@ func decodeContentPart(raw json.RawMessage) (model.ContentPart, error) { return model.NewContentPart(part) } -// marshalExtra renders one Extra as a JSON object after checking every value -// is complete valid JSON, so the raw value bytes pass through verbatim. +// marshalExtra frames each opaque key and value after validating JSON syntax. func marshalExtra(e model.Extra) (json.RawMessage, error) { if !validExtraValues(e) { return nil, invalidInput("extra values must be complete valid JSON") } - wire, err := json.Marshal(e) + framed := make(map[string]string, len(e)) + for k, v := range e { + framed[base64.StdEncoding.EncodeToString([]byte(k))] = base64.StdEncoding.EncodeToString(v) + } + wire, err := json.Marshal(framed) if err != nil { return nil, err } @@ -414,7 +441,7 @@ func encodeToolDefinition(d model.ToolDefinition) (json.RawMessage, error) { if obj["description"], err = json.Marshal(d.Description); err != nil { return nil, err } - obj["parameters"] = model.CloneRaw(d.Parameters) + obj["parameters_base64"] = frame(d.Parameters) return json.Marshal(obj) } @@ -425,7 +452,7 @@ func decodeToolDefinition(raw json.RawMessage) (model.ToolDefinition, error) { if err != nil { return model.ToolDefinition{}, fmt.Errorf("tool definition: %w", err) } - if err := rejectUnknownMembers(obj, "name", "description", "parameters"); err != nil { + if err := rejectUnknownMembers(obj, "name", "description", "parameters_base64"); err != nil { return model.ToolDefinition{}, err } var d model.ToolDefinition @@ -435,7 +462,7 @@ func decodeToolDefinition(raw json.RawMessage) (model.ToolDefinition, error) { if d.Description, err = stringMember(obj, "description", true); err != nil { return model.ToolDefinition{}, err } - params, err := member(obj, "parameters", true) + params, err := framedMember(obj, "parameters_base64", true) if err != nil { return model.ToolDefinition{}, err } diff --git a/harness/codec_test.go b/harness/codec_test.go index a2859d4e..907d29c9 100644 --- a/harness/codec_test.go +++ b/harness/codec_test.go @@ -627,7 +627,7 @@ func TestHookResultStatusRules(t *testing.T) { failed := validHookResultEntry(hookFailed) failedRaw, err := encodeHookResultEntry(failed) testassert.NoError(t, err) - return setKey(failedRaw, "arguments", json.RawMessage(`{}`)) + return setKey(failedRaw, "arguments_base64", frame([]byte(`{}`))) }()}, } { t.Run(mutation.name, func(t *testing.T) { @@ -1002,6 +1002,7 @@ func TestUsageAccounting(t *testing.T) { func TestToolCallNormalizedArgumentsNullRejected(t *testing.T) { entry := validAssistantEntry(testOpID) entry.AdvertisedNames = []string{"echo"} + entry.Evidence.ToolNames = []string{"echo"} call := validToolCallRecord() call.NormalizedArguments = json.RawMessage("null") entry.ToolCalls = []toolCallRecord{call} @@ -1032,6 +1033,7 @@ func TestToolCallNormalizedArgumentsObjectOnly(t *testing.T) { const wrong = `[1,2]` entry := validAssistantEntry(testOpID) entry.AdvertisedNames = []string{"echo"} + entry.Evidence.ToolNames = []string{"echo"} call := validToolCallRecord() call.NormalizedArguments = json.RawMessage(wrong) entry.ToolCalls = []toolCallRecord{call} @@ -1044,6 +1046,7 @@ func TestToolCallNormalizedArgumentsObjectOnly(t *testing.T) { // kind and prove decode rejects it. valid := validAssistantEntry(testOpID) valid.AdvertisedNames = []string{"echo"} + valid.Evidence.ToolNames = []string{"echo"} vcall := validToolCallRecord() vcall.NormalizedArguments = json.RawMessage(`{"x":1}`) valid.ToolCalls = []toolCallRecord{vcall} @@ -1059,7 +1062,7 @@ func TestToolCallNormalizedArgumentsObjectOnly(t *testing.T) { err := json.Unmarshal(obj["tool_calls"], &items) testassert.Require(t, err == nil && len(items) == 1, "encoded tool_calls = %s (%v)", obj["tool_calls"], err) } - items[0]["normalized_arguments"] = json.RawMessage(wrong) + items[0]["normalized_arguments_base64"] = frame([]byte(wrong)) edited, err := json.Marshal(items) testassert.NoError(t, err) obj["tool_calls"] = edited @@ -1245,6 +1248,7 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { // when absent. advertised := validAssistantEntry(testOpID) advertised.AdvertisedNames = []string{"echo"} + advertised.Evidence.ToolNames = []string{"echo"} failed := validToolCallRecord() failed.NormalizationFailure = diag advertised.ToolCalls = []toolCallRecord{failed} @@ -1252,7 +1256,7 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { { _, present := member["normalization_failure"] testassert.Require(t, present, "encoded call %s carries no normalization_failure member", member) - _, present = member["normalized_arguments"] + _, present = member["normalized_arguments_base64"] testassert.Require(t, !present, "encoded call %s carries both normalization members", member) } env := Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: raw} @@ -1288,6 +1292,7 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { t.Run(tc.name, func(t *testing.T) { v := validAssistantEntry(testOpID) v.AdvertisedNames = tc.advertised + v.Evidence.ToolNames = tc.advertised v.ToolCalls = []toolCallRecord{tc.call} { _, err := encodeAssistantEntry(v) @@ -1301,6 +1306,7 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { t.Run("bounded diagnostic", func(t *testing.T) { v := validAssistantEntry(testOpID) v.AdvertisedNames = []string{"echo"} + v.Evidence.ToolNames = []string{"echo"} call := validToolCallRecord() call.NormalizationFailure = strings.Repeat("x", maxToolDiagnosticBytes+1) v.ToolCalls = []toolCallRecord{call} @@ -1338,7 +1344,7 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { } { items[0]["normalization_failure"] = tc.member if tc.name == "both members" { - items[0]["normalized_arguments"] = json.RawMessage(`{"x":1}`) + items[0]["normalized_arguments_base64"] = frame([]byte(`{"x":1}`)) } edited, err := json.Marshal(items) testassert.NoError(t, err) @@ -1350,7 +1356,7 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { _, err := decodeAssistantEntry(env) testassert.Require(t, err != nil, "decode %s must fail", tc.name) } - delete(items[0], "normalized_arguments") + delete(items[0], "normalized_arguments_base64") } }) } @@ -1367,7 +1373,7 @@ func TestToolResultMetadataRules(t *testing.T) { raw, err := encodeToolResultEntry(v) testassert.NoError(t, err) { - _, present := wireObject(t, raw)["metadata"] + _, present := wireObject(t, raw)["metadata_base64"] testassert.Require(t, present, "encoded payload %s carries no metadata member", raw) } env := Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryToolResult, Payload: raw} @@ -1383,7 +1389,7 @@ func TestToolResultMetadataRules(t *testing.T) { bare, err := encodeToolResultEntry(validToolResultEntry(testOpID)) testassert.NoError(t, err) { - _, present := wireObject(t, bare)["metadata"] + _, present := wireObject(t, bare)["metadata_base64"] testassert.Require(t, !present, "metadata-less payload encodes the member: %s", bare) } @@ -1394,7 +1400,6 @@ func TestToolResultMetadataRules(t *testing.T) { {"null metadata", json.RawMessage(`null`)}, {"malformed metadata", json.RawMessage(`{broken`)}, {"oversized metadata", json.RawMessage(`"` + strings.Repeat("x", maxToolMetadataBytes) + `"`)}, - {"raw-at-bound metadata whose HTML-escaped durable encoding exceeds the bound", json.RawMessage(`"` + strings.Repeat("<", maxToolMetadataBytes-2) + `"`)}, } for _, tc := range rejected { t.Run(tc.name, func(t *testing.T) { @@ -1424,7 +1429,7 @@ func TestToolResultMetadataRules(t *testing.T) { {"at the bound", json.RawMessage(`"` + strings.Repeat("x", maxToolMetadataBytes-2) + `"`)}, } { env := env - env.Payload = setKey(raw, "metadata", tc.metadata) + env.Payload = setKey(raw, "metadata_base64", frame(tc.metadata)) _, err := decodeToolResultEntry(env) if tc.name == "at the bound" { testassert.NoError(t, err) @@ -1434,14 +1439,11 @@ func TestToolResultMetadataRules(t *testing.T) { } } -// TestOwnedToolMetadataEncodedForm proves the shared owning helper returns -// the exact durable encoding json.Marshal produces for the raw input — -// whitespace compacts, HTML characters escape, numeric lexemes stay exact — -// never a clone of the caller's raw bytes: the caller's buffer stays -// unchanged and the returned bytes are independent of it. +// TestOwnedToolMetadataEncodedForm proves metadata retains its exact bytes +// and the returned buffer is independent of the caller. func TestOwnedToolMetadataEncodedForm(t *testing.T) { const input = `{ "n" : [1, 1.0, 1e0, 9007199254740993], "s" : "<>&" }` - const want = `{"n":[1,1.0,1e0,9007199254740993],"s":"\u003c\u003e\u0026"}` + const want = input raw := json.RawMessage(input) got := ownedToolMetadata(raw) testassert.Require(t, string(got) == want, "owned metadata = %s, want the durable encoded bytes %s", got, want) @@ -1724,11 +1726,11 @@ func TestCodecOwnedValues(t *testing.T) { // bytes as independent of them. func TestAdoptedFormsAcrossInsertAndFork(t *testing.T) { const ( - verbatimExtra = `{"path":"a/b","list":[1,2]}` - nestedExtra = `{"deep":{"a":[{"b":1},[[2],"c"]]},"keep":true}` - lexemeExtra = `{"n":[1,1.0,1e0,9007199254740993,-0]}` - nestedArguments = `{"deep":{"a":[{"b":1},[[2],"c"]]},"n":[1,1.0,1e0,9007199254740993]}` - nestedMetadata = `{"marker":[{"tool":{"nested":[1,{"x":[2,3]}]}}]}` + verbatimExtra = ` { "path" : "", "list" : [1, 2] } ` + nestedExtra = ` { "deep" : {"a":[{"b":1},[[2],""]]},"keep":true} ` + lexemeExtra = ` {"n": [1, 1.0, 1e0, 9007199254740993, -0]} ` + nestedArguments = ` {"deep": {"a":[{"b":1},[[2],""]]}, "n": [1,1.0,1e0,9007199254740993]} ` + nestedMetadata = ` {"marker": [{"tool":{"nested":[1,{"x":[2,3]}]}}], "s":"<"} ` ) // wantReplacedText pins one U+FFFD per invalid byte with the ordinary // multi-byte text around it untouched. @@ -1758,11 +1760,12 @@ func TestAdoptedFormsAcrossInsertAndFork(t *testing.T) { v := validAssistantEntry(testOpID) v.EntryID = entryID v.AdvertisedNames = []string{"echo"} + v.Evidence.ToolNames = []string{"echo"} v.Content = []model.ContentPart{ {Kind: model.PartText, Text: invalidText(), Extra: model.Extra{"raw": freshRaw(verbatimExtra), "utf8": invalidRawString()}}, {Kind: model.PartOpaque, OpaqueWireType: "thinking", Extra: model.Extra{"nested": freshRaw(nestedExtra), "lexemes": freshRaw(lexemeExtra)}}, } - v.Extra = model.Extra{"msg": freshRaw(nestedExtra)} + v.Extra = model.Extra{"msg": freshRaw(nestedExtra), "": freshRaw(`true`), string([]byte{0xff}): freshRaw(`"invalid key"`)} v.ToolCalls = []toolCallRecord{call, failed} return v } @@ -1796,6 +1799,7 @@ func TestAdoptedFormsAcrossInsertAndFork(t *testing.T) { testassert.Require(t, string(got.Content[1].Extra["nested"]) == nestedExtra, "adopted nested content extra = %q, want the nested form unchanged", got.Content[1].Extra["nested"]) testassert.Require(t, string(got.Content[1].Extra["lexemes"]) == lexemeExtra, "adopted numeric lexemes = %q, want 9007199254740993 and its siblings exact", got.Content[1].Extra["lexemes"]) testassert.Require(t, string(got.Extra["msg"]) == nestedExtra, "adopted message extra = %q, want the nested form unchanged", got.Extra["msg"]) + testassert.Require(t, string(got.Extra[""]) == `true` && string(got.Extra[string([]byte{0xff})]) == `"invalid key"`, "adopted opaque keys = %+v", got.Extra) testassert.Require(t, string(got.ToolCalls[0].Extra["nested"]) == nestedExtra, "adopted call extra = %q, want the nested form unchanged", got.ToolCalls[0].Extra["nested"]) testassert.Require(t, string(got.ToolCalls[0].NormalizedArguments) == nestedArguments, "adopted normalized arguments = %q, want the nested form with exact lexemes unchanged", got.ToolCalls[0].NormalizedArguments) testassert.Require(t, got.ToolCalls[1].NormalizationFailure == "arguments must be one non-null JSON object", "adopted normalization failure = %q, want the stored diagnostic verbatim", got.ToolCalls[1].NormalizationFailure) @@ -2481,6 +2485,7 @@ func TestCompactionEntryPayloadRules(t *testing.T) { t.Run("round trips owned with and without fact usage", func(t *testing.T) { for _, withUsage := range []bool{false, true} { v := validCompactionEntry(testOpID) + v.Evidence.ToolNames = []string{} if withUsage { v.Facts[0].Usage = &UsageCount{InputTokens: 11, CachedInputTokens: 4, OutputTokens: 7} } @@ -2852,14 +2857,13 @@ func TestAssistantEntryRequestEvidenceRequiredMembers(t *testing.T) { } // Present-empty prompt contribution survives the wire as present. v := validAssistantEntry(testOpID) - empty := "" - v.Evidence.Prompt = &empty + v.Evidence.Prompt = "" v.Evidence.ToolNames = []string{} raw, err = encodeAssistantEntry(v) testassert.NoError(t, err) decoded, err = decodeAssistantEntry(Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: raw}) testassert.NoError(t, err) - testassert.Require(t, decoded.Evidence.Prompt != nil && *decoded.Evidence.Prompt == "", "prompt contribution = %+v, want the present-empty prompt retained", decoded.Evidence.Prompt) + testassert.Require(t, decoded.Evidence.Prompt == "", "prompt contribution = %+v, want the empty prompt retained", decoded.Evidence.Prompt) testassert.Require(t, decoded.Evidence.ToolNames != nil && len(decoded.Evidence.ToolNames) == 0, "tool view = %+v, want the present-empty view retained", decoded.Evidence.ToolNames) // Decoded evidence is deep-owned: mutating it cannot reach a later decode. decoded.Evidence.BoundaryEntryID = hexID(9) @@ -2868,7 +2872,7 @@ func TestAssistantEntryRequestEvidenceRequiredMembers(t *testing.T) { } again, err := decodeAssistantEntry(Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: raw}) testassert.NoError(t, err) - testassert.Require(t, again.Evidence.BoundaryEntryID == hexID(1) && again.Evidence.Prompt != nil && *again.Evidence.Prompt == "", "payload bytes changed by a decoded mutation") + testassert.Require(t, again.Evidence.BoundaryEntryID == hexID(1) && again.Evidence.Prompt == "", "payload bytes changed by a decoded mutation") } // TestRequestEvidenceContributionMessageDomain proves the shared message gate @@ -2923,7 +2927,7 @@ func TestRequestEvidenceContributionMessageDomain(t *testing.T) { testassert.Require(t, err != nil && strings.Contains(err.Error(), "call id"), "encode-side tool-result call id: %v", err) } { - badRaw := setKey(raw, "messages", json.RawMessage(`[{"role":"assistant","content":[{"kind":"text","text":"wire"}],"tool_calls":[{"id":"c","name":"echo","arguments":"{}"}]}]`)) + badRaw := setKey(raw, "messages", json.RawMessage(`[{"role":"assistant","source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":"wire"}],"tool_calls":[{"id":"c","name":"echo","arguments_base64":"e30="}]}]`)) _, err := decodeRequestEvidence(badRaw) testassert.Require(t, err != nil, "decode-side tool-call contribution must reject") } @@ -3099,12 +3103,12 @@ func TestStrategyCaptureSettingsRawAcceptance(t *testing.T) { obj, err := decodePayloadObject(encode(t, nil)) testassert.NoError(t, err) if settings != "" { - obj["strategy"] = json.RawMessage(`{"id":"test.strategy/v1","settings":` + settings + `,"requires_context_window":false}`) + obj["strategy"] = json.RawMessage(`{"id":"test.strategy/v1","settings_base64":` + string(frame([]byte(settings))) + `,"requires_context_window":false}`) } return obj } - for _, accepted := range []string{"null", "1e999", `{"nested":[1,{"a":"b"}]}`, "[]"} { + for _, accepted := range []string{"null", "1e999", ` { "nested" : [1,{"a":""}] } `, "[]", string([]byte{'"', 0xff, '"'})} { obj := with(accepted) got, err := decodeExecutionCapture(obj) testassert.Require(t, err == nil && string(got.Strategy.Settings) == accepted, "settings %s = %s err %v, want the original accepted bytes", accepted, got.Strategy.Settings, err) @@ -3131,7 +3135,7 @@ func TestStrategyCaptureSettingsRawAcceptance(t *testing.T) { testassert.Require(t, present, "the strategy member is required") members, err := decodePayloadObject(strategy) testassert.NoError(t, err) - _, has := members["settings"] + _, has := members["settings_base64"] testassert.Require(t, !has, "absent settings leaked the settings member") } @@ -3163,6 +3167,152 @@ func TestStrategyCaptureSettingsSurviveColdReadAndRecovery(t *testing.T) { testassert.Require(t, string(rec.Admission.Execution.Strategy.Settings) == "1e999", "post-recovery settings = %s, want the admitted bytes", rec.Admission.Execution.Strategy.Settings) } +func TestOpaqueFramesPreserveBytesAndRejectInvalidFrames(t *testing.T) { + invalidKey := string([]byte{0xff}) + invalidJSON := json.RawMessage{'"', 0xff, '"'} + part := model.ContentPart{Kind: model.PartText, Text: "text", Extra: model.Extra{ + "": json.RawMessage(` { "s" : "<>&" } `), invalidKey: invalidJSON, + }} + wire, err := encodeContentPart(part) + testassert.NoError(t, err) + got, err := decodeContentPart(wire) + testassert.NoError(t, err) + if !reflect.DeepEqual(part, got) { + t.Fatalf("opaque extra changed: got %+v want %+v", got.Extra, part.Extra) + } + invalidJSON[1] = 'x' + if got.Extra[invalidKey][1] != 0xff { + t.Fatal("decoded bytes alias input") + } + for _, bad := range []json.RawMessage{json.RawMessage(`null`), json.RawMessage(`42`), json.RawMessage(`"a"`), json.RawMessage(`"YQ==\n"`), json.RawMessage(`"YR=="`), json.RawMessage(`""`)} { + obj := wireObject(t, wire) + obj["extra"] = mustMarshal(map[string]json.RawMessage{"aw==": bad}) + broken := mustMarshal(obj) + if _, err := decodeContentPart(broken); err == nil { + t.Errorf("accepted invalid value frame %s", bad) + } + } + for _, key := range []string{"a", "YR==", "YQ==\n"} { + obj := wireObject(t, wire) + obj["extra"] = mustMarshal(map[string]json.RawMessage{key: frame([]byte(`true`))}) + if _, err := decodeContentPart(mustMarshal(obj)); err == nil { + t.Errorf("accepted invalid key frame %q", key) + } + } + definition := testToolDefinition() + definition.Parameters = json.RawMessage(` { "type" : "object", "s" : "<" } `) + wire, err = encodeToolDefinition(definition) + testassert.NoError(t, err) + d, err := decodeToolDefinition(wire) + testassert.NoError(t, err) + if string(d.Parameters) != string(definition.Parameters) { + t.Fatalf("parameters changed: %s", d.Parameters) + } + obj := wireObject(t, wire) + delete(obj, "parameters_base64") + if _, err := decodeToolDefinition(mustMarshal(obj)); err == nil { + t.Fatal("missing parameters accepted") + } + obj["parameters_base64"] = frame([]byte(`[]`)) + if _, err := decodeToolDefinition(mustMarshal(obj)); err == nil { + t.Fatal("non-object parameters accepted") + } + for _, bad := range []json.RawMessage{json.RawMessage(`null`), json.RawMessage(`1`), json.RawMessage(`"bad!"`), json.RawMessage(`"YR=="`)} { + obj["parameters_base64"] = bad + if _, err := decodeToolDefinition(mustMarshal(obj)); err == nil { + t.Errorf("accepted parameter frame %s", bad) + } + strategy := map[string]json.RawMessage{"id": mustMarshal("test.strategy/v1"), "requires_context_window": mustMarshal(false), "settings_base64": bad} + if _, err := decodeStrategyCapture(strategy); err == nil { + t.Errorf("accepted settings frame %s", bad) + } + } +} + +func TestRequestEvidenceRequiresConcretePromptAndNames(t *testing.T) { + for _, names := range [][]string{nil, {}} { + raw, err := encodeRequestEvidence(RequestEvidence{BoundaryEntryID: hexID(1), ToolNames: names}) + testassert.NoError(t, err) + got, err := decodeRequestEvidence(raw) + testassert.NoError(t, err) + if got.Prompt != "" || got.ToolNames == nil || len(got.ToolNames) != 0 { + t.Fatalf("evidence = %+v", got) + } + for _, key := range []string{"prompt", "tool_names"} { + for _, broken := range []json.RawMessage{dropKey(raw, key), setKey(raw, key, json.RawMessage(`null`))} { + if _, err := decodeRequestEvidence(broken); err == nil { + t.Errorf("accepted malformed %s: %s", key, broken) + } + } + } + } + assistant := validAssistantEntry(testOpID) + assistant.AdvertisedNames = []string{"echo"} + if _, err := encodeAssistantEntry(assistant); err == nil { + t.Fatal("mismatched advertisement accepted") + } + assistant.Evidence.ToolNames = []string{"echo"} + if _, err := encodeAssistantEntry(assistant); err != nil { + t.Fatal(err) + } + if _, err := encodeRequestEvidence(RequestEvidence{BoundaryEntryID: hexID(1), ToolNames: []string{"echo", "echo"}}); err == nil { + t.Fatal("duplicate evidence names accepted") + } +} + +func TestArgumentAndMetadataFramesAcrossOwners(t *testing.T) { + const raw = ` { "n" : 1e999, "text" : "<" } ` + hook := validHookResultEntry(hookSucceeded) + hook.Arguments = json.RawMessage(raw) + hookWire, err := encodeHookResultEntry(hook) + testassert.NoError(t, err) + hookEnv := Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryHookResult, Payload: hookWire} + gotHook, err := decodeHookResultEntry(hookEnv) + testassert.NoError(t, err) + if string(gotHook.Arguments) != raw { + t.Fatalf("hook arguments changed: %s", gotHook.Arguments) + } + effect := ActiveEffect{Kind: EffectTool, ResultEntryID: hexID(9), ToolCallID: "call-1", FinalArguments: json.RawMessage(raw)} + effectWire, err := encodeActiveEffect(effect) + testassert.NoError(t, err) + gotEffect, err := decodeActiveEffect(wireObject(t, effectWire)) + testassert.NoError(t, err) + if string(gotEffect.FinalArguments) != raw { + t.Fatalf("active arguments changed: %s", gotEffect.FinalArguments) + } + result := validToolResultEntry(testOpID) + result.NormalizedArguments, result.Metadata = json.RawMessage(raw), json.RawMessage(raw) + resultWire, err := encodeToolResultEntry(result) + testassert.NoError(t, err) + resultEnv := Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryToolResult, Payload: resultWire} + gotResult, err := decodeToolResultEntry(resultEnv) + testassert.NoError(t, err) + if string(gotResult.NormalizedArguments) != raw || string(gotResult.Metadata) != raw { + t.Fatalf("result bytes changed: %+v", gotResult) + } + for _, malformed := range []json.RawMessage{json.RawMessage(`null`), json.RawMessage(`false`), json.RawMessage(`"bad!"`), frame([]byte(`[]`))} { + hookEnv.Payload = setKey(hookWire, "arguments_base64", malformed) + if _, err := decodeHookResultEntry(hookEnv); err == nil { + t.Errorf("accepted hook arguments %s", malformed) + } + obj := wireObject(t, effectWire) + obj["final_arguments_base64"] = malformed + if _, err := decodeActiveEffect(obj); err == nil { + t.Errorf("accepted active arguments %s", malformed) + } + resultEnv.Payload = setKey(resultWire, "normalized_arguments_base64", malformed) + if _, err := decodeToolResultEntry(resultEnv); err == nil { + t.Errorf("accepted normalized arguments %s", malformed) + } + } + for _, malformed := range []json.RawMessage{json.RawMessage(`null`), json.RawMessage(`false`), json.RawMessage(`"bad!"`), frame([]byte(`null`)), frame([]byte(`{`))} { + resultEnv.Payload = setKey(resultWire, "metadata_base64", malformed) + if _, err := decodeToolResultEntry(resultEnv); err == nil { + t.Errorf("accepted metadata %s", malformed) + } + } +} + // TestOperationStateUnreportedMaintenanceCodec proves the incomplete-maintenance // marker's durable wire: it round-trips on a running and on a terminal // Operation state, a false marker encodes as nothing, and a malformed member diff --git a/harness/compact.go b/harness/compact.go index ac0f5a6f..d880a941 100644 --- a/harness/compact.go +++ b/harness/compact.go @@ -570,8 +570,8 @@ func (h *Harness) runContextMaintenance(execCtx context.Context, c *coordinator, } // The consumed evidence names the frozen input references — never the // proposed cut — and the frozen contribution view replaces it with the - // same references plus their effective deltas. - facts.consumed = RequestEvidence{CheckpointEntryID: input.CheckpointEntryID, BoundaryEntryID: input.BoundaryEntryID} + // same references plus the effective contribution view. + facts.consumed = newRequestView(capture, input.CheckpointEntryID, input.BoundaryEntryID, nil).evidenceFor() if input.Contributions != nil { facts.consumed = *input.Contributions } @@ -708,6 +708,17 @@ func (h *Harness) runMaintenanceModel(execCtx context.Context, c *coordinator, o res, runErr := agent.Run(execCtx, agent.Invocation{ ExpectedModel: capture.Compact.Model, Request: func(context.Context) (agent.RequestResult, error) { + if err := model.ValidateRequest(request); err == nil { + raw, err := encodeMaintenanceInput(request.Messages) + if err != nil { + return agent.RequestResult{}, err + } + messages, err := decodeMaintenanceInput(raw) + if err != nil { + return agent.RequestResult{}, err + } + request.Messages = messages + } return agent.RequestResult{Request: &request}, nil }, ModelEffect: effect, @@ -1054,7 +1065,7 @@ func (h *Harness) commitCheckpoint(execCtx context.Context, c *coordinator, oper } // The consumed evidence names the frozen input references — // never the proposed cut. - consumed := RequestEvidence{CheckpointEntryID: input.CheckpointEntryID, BoundaryEntryID: input.BoundaryEntryID} + consumed := newRequestView(capture, input.CheckpointEntryID, input.BoundaryEntryID, nil).evidenceFor() // The merged carrier holds this checkpoint's facts exactly once, // the accumulated uncommitted facts already in incurred order // before the committing procedure's own. diff --git a/harness/composition_test.go b/harness/composition_test.go index 71506a1f..e54bdcc0 100644 --- a/harness/composition_test.go +++ b/harness/composition_test.go @@ -35,10 +35,11 @@ func compositionFixtureRequest(t *testing.T, h *Harness, c *coordinator, exec Ex return completedTurnStream(), nil } source := h.requestSource(c, testOpID, capture, exec, inv, round) - if _, err := source(context.Background()); err != nil { + request, err := source(context.Background()) + if err != nil { return agent.ModelSettlement{}, model.Request{}, err } - set, err := invokeModelEffect(t, h.modelEffect(c, testOpID, exec, inv, capture, round), defaultModelRequest(t), nil) + set, err := invokeModelEffect(t, h.modelEffect(c, testOpID, exec, inv, capture, round), *request.Request, nil) if err != nil { return agent.ModelSettlement{}, model.Request{}, err } @@ -77,7 +78,7 @@ func TestRequestHookPurePromptRemoval(t *testing.T) { testassert.Require(t, len(sent.Messages) > 0 && sent.Messages[0].Role != model.RoleSystem, "dispatch request = %+v, want no system prompt message after a present-empty prompt", sent.Messages) assistant := compositionAssistant(t, store, sessionID) - testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.Prompt != nil && *assistant.Evidence.Prompt == "", "assistant evidence = %+v, want the recorded empty-prompt contribution", assistant.Evidence) + testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.Prompt == "", "assistant evidence = %+v, want the recorded empty-prompt contribution", assistant.Evidence) testassert.Require(t, len(assistant.Evidence.HookIDs) == 1 && assistant.Evidence.HookIDs[0].HookID == "req.prompt.remove" && assistant.Evidence.HookIDs[0].ExtensionID == "req-ext", "assistant evidence hook identities = %+v, want the pure contributor recorded", assistant.Evidence.HookIDs) graph, err := validateFixture(t, store, sessionID) @@ -135,7 +136,7 @@ func TestRequestHookPureToolView(t *testing.T) { testassert.NoError(t, err) testassert.Require(t, len(sent.Tools) == 1 && sent.Tools[0].Name == "echo", "dispatch tools = %+v, want the restored full catalog", sent.Tools) assistant := compositionAssistant(t, store, sessionID) - testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.ToolNames == nil, "assistant evidence = %+v, want no net tool-view delta", assistant.Evidence) + testassert.Require(t, assistant.Evidence != nil && reflect.DeepEqual(assistant.Evidence.ToolNames, []string{"echo"}), "assistant evidence = %+v, want restored tool view", assistant.Evidence) testassert.Require(t, len(assistant.Evidence.HookIDs) == 2 && assistant.Evidence.HookIDs[0].HookID == "req.tools.narrow" && assistant.Evidence.HookIDs[1].HookID == "req.tools.restore", "assistant evidence hook identities = %+v, want both accepted hooks in configured order", assistant.Evidence.HookIDs) }) } @@ -167,7 +168,7 @@ func TestRequestHookFrozenInputOwnership(t *testing.T) { testassert.Require(t, len(sent.Tools) == 1 && sent.Tools[0].Name == "echo", "dispatch tools = %+v, want the captured catalog unchanged by hook mutation", sent.Tools) testassert.Require(t, strings.Contains(sent.Messages[0].TextContent(), "system") && !strings.Contains(sent.Messages[0].TextContent(), "mutated"), "dispatch prompt = %q, want the captured prompt unchanged", sent.Messages[0].TextContent()) assistant := compositionAssistant(t, store, sessionID) - testassert.Require(t, len(assistant.Evidence.HookIDs) == 1 && assistant.Evidence.HookIDs[0].HookID == "req.observe" && assistant.Evidence.Prompt == nil && assistant.Evidence.Messages == nil, "assistant evidence = %+v, want the accepted observer identity with no contribution from an empty consequence", assistant.Evidence) + testassert.Require(t, len(assistant.Evidence.HookIDs) == 1 && assistant.Evidence.HookIDs[0].HookID == "req.observe" && assistant.Evidence.Prompt == testCapture().SystemPrompt && assistant.Evidence.Messages == nil, "assistant evidence = %+v, want the accepted observer identity and captured prompt", assistant.Evidence) } // TestHookFamiliesDisjointForSameIdentity proves the binding-isolation rows: @@ -216,7 +217,7 @@ func TestHookFamiliesDisjointForSameIdentity(t *testing.T) { assistant := compositionAssistant(t, store, sessionID) testassert.Require(t, assistant.Evidence != nil && len(assistant.Evidence.HookIDs) == 1 && assistant.Evidence.HookIDs[0].HookID == sharedID && assistant.Evidence.HookIDs[0].ExtensionID == sharedExt, "assistant evidence hook identities = %+v, want only the request-bound family record", assistant.Evidence.HookIDs) - testassert.Require(t, assistant.Evidence.Prompt != nil && *assistant.Evidence.Prompt == "shared request prompt", "assistant evidence = %+v, want the request family's consumed prompt contribution", assistant.Evidence) + testassert.Require(t, assistant.Evidence.Prompt == "shared request prompt", "assistant evidence = %+v, want the request family's consumed prompt contribution", assistant.Evidence) graph, err := validateFixture(t, store, sessionID) testassert.NoError(t, err) @@ -509,16 +510,20 @@ func TestModelAttemptDeepOwnedRequestCopies(t *testing.T) { exec := effectExecution(modelFn, nil) exec.Retry = (&retrySpy{limit: 1}).retry h, store, c, sessionID := newEffectHarness(t, nil) - inv := InvocationExecution{Model: modelFn, NormalizeTool: objectNormalize} + inv := InvocationExecution{Model: modelFn, NormalizeTool: objectNormalize, RequestHooks: []RequestHook{{ID: "req.copy", ExtensionID: "req-ext", Mode: HookModePure, Run: func(context.Context, RequestHookInput) (RequestConsequence, error) { + return RequestConsequence{Messages: []model.Message{{Role: model.RoleUser, Content: []model.ContentPart{{Kind: model.PartText, Text: "extra", Extra: model.Extra{"raw": json.RawMessage(` { "x" : "<" } `)}}}}}}, nil + }}}} round := &conversationRound{} source := h.requestSource(c, testOpID, testCapture(), exec, inv, round) req, err := source(context.Background()) testassert.NoError(t, err) // Record a fingerprint and mutate the callback-owned view at attempt one. inv.Model = func(_ context.Context, r model.Request) (model.Stream, error) { - fingerprint := fmt.Sprintf("%d/%d/%s", len(r.Messages), len(r.Tools), r.Messages[len(r.Messages)-1].TextContent()) + fingerprint := fmt.Sprintf("%d/%d/%s/%s/%s", len(r.Messages), len(r.Tools), r.Messages[len(r.Messages)-1].TextContent(), r.Messages[len(r.Messages)-1].Content[0].Extra["raw"], r.Tools[0].Parameters) seen = append(seen, fingerprint) r.Messages[len(r.Messages)-1].Content[0].Text = "mutated" + r.Messages[len(r.Messages)-1].Content[0].Extra["raw"][0] = 'X' + r.Tools[0].Parameters[0] = 'X' r.Tools = append(r.Tools, model.ToolDefinition{Name: "extra", Parameters: json.RawMessage(`{"type":"object"}`)}) return modelFn(context.TODO(), r) } @@ -529,6 +534,7 @@ func TestModelAttemptDeepOwnedRequestCopies(t *testing.T) { testassert.Require(t, len(seen) == 2 && seen[0] == seen[1], "attempt views differ after mutation: %v", seen) assistant := compositionAssistant(t, store, sessionID) testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.BoundaryEntryID != "", "assistant evidence = %+v, want the untouched logical request evidence", assistant.Evidence) + testassert.Require(t, string(assistant.Evidence.Messages[0].Content[0].Extra["raw"]) == ` { "x" : "<" } ` && reflect.DeepEqual(assistant.AdvertisedNames, []string{"echo"}), "mutation reached evidence or advertisement") { if _, err := validateFixture(t, store, sessionID); err != nil { t.Fatalf("graph validation: %v", err) @@ -536,6 +542,71 @@ func TestModelAttemptDeepOwnedRequestCopies(t *testing.T) { } } +func TestPhysicalDispatchMatchesSourceRequest(t *testing.T) { + h, store, c, sessionID := newEffectHarness(t, nil) + contribution := model.Message{Role: model.RoleUser, Content: []model.ContentPart{{Kind: model.PartText, Text: "added", Extra: model.Extra{"raw": json.RawMessage(` { "value" : "" } `)}}}} + hook := RequestHook{ID: "req.extra", ExtensionID: "req-ext", Mode: HookModePure, Run: func(context.Context, RequestHookInput) (RequestConsequence, error) { + return RequestConsequence{Messages: []model.Message{contribution}}, nil + }} + var sent model.Request + var intentEvidence RequestEvidence + modelFn := func(_ context.Context, r model.Request) (model.Stream, error) { + sent = r + c.mu.Lock() + op, ok := c.graph.Operation(testOpID) + if !ok || op.State.ActiveEffect == nil || op.State.ActiveEffect.Evidence == nil { + c.mu.Unlock() + t.Fatal("model callback has no committed intent") + } + intentEvidence = copyRequestEvidence(*op.State.ActiveEffect.Evidence) + c.mu.Unlock() + return completedTurnStream(), nil + } + exec := effectExecution(modelFn, nil) + inv := InvocationExecution{Model: modelFn, NormalizeTool: objectNormalize, RequestHooks: []RequestHook{hook}} + round := &conversationRound{} + source := h.requestSource(c, testOpID, testCapture(), exec, inv, round) + var sourceRequest model.Request + _, err := agent.Run(context.Background(), agent.Invocation{ + ExpectedModel: testCapture().Model, + Request: func(ctx context.Context) (agent.RequestResult, error) { + result, err := source(ctx) + if result.Request != nil { + sourceRequest = *result.Request + } + return result, err + }, + ModelEffect: h.modelEffect(c, testOpID, exec, inv, testCapture(), round), + ToolEffect: func(context.Context, model.ToolCall, []string) (model.ToolResult, error) { + return model.ToolResult{}, errors.New("unexpected tool call") + }, + }) + testassert.NoError(t, err) + if !reflect.DeepEqual(sourceRequest, sent) { + t.Fatalf("physical request differs from source: source=%+v sent=%+v", sourceRequest, sent) + } + want := model.Request{ + Messages: []model.Message{ + {Role: model.RoleSystem, Content: []model.ContentPart{{Kind: model.PartText, Text: "system"}}}, + {Role: model.RoleUser, Content: []model.ContentPart{{Kind: model.PartText, Text: "hello"}}}, + contribution, + }, + Tools: []model.ToolDefinition{testToolDefinition()}, + } + if !reflect.DeepEqual(sent, want) { + t.Fatalf("physical request = %+v, want independently authored %+v", sent, want) + } + assistant := compositionAssistant(t, store, sessionID) + if !reflect.DeepEqual(intentEvidence, *assistant.Evidence) { + t.Fatalf("intent evidence %+v differs from cold assistant evidence %+v", intentEvidence, *assistant.Evidence) + } + graph, err := validateFixture(t, store, sessionID) + testassert.NoError(t, err) + if assistant.Evidence.BoundaryEntryID != graph.Entries[0].Envelope.ID || assistant.Evidence.Prompt != "system" || !reflect.DeepEqual(assistant.Evidence.ToolNames, []string{"echo"}) || !reflect.DeepEqual(assistant.Evidence.Messages, []model.Message{contribution}) || !reflect.DeepEqual(graph.Operations[0].Admission.Execution.Tools, want.Tools) { + t.Fatalf("cold evidence differs from accepted request: %+v", assistant.Evidence) + } +} + // TestRequestHookConsequenceToolMembersRejectBeforeDispatch proves the shared // message rule at the hook consequence gate: a consequence carrying tool // execution members rejects before any view advance, the pure chain commits @@ -581,8 +652,8 @@ func TestRequestMessageCopyOwnsHistoricalToolCalls(t *testing.T) { func TestMaterializedEmptyToolSelectionStaysEmpty(t *testing.T) { capture := testCapture() testassert.Require(t, len(capture.Tools) > 0, "the captured catalog must be nonempty") - view := materializeRequestView(capture, nil, RequestEvidence{BoundaryEntryID: testEntryID, ToolNames: []string{}}) - testassert.Require(t, view.names != nil && len(view.effectiveTools()) == 0, "present-empty selection became the full catalog") + req := buildDispatchRequest(capture, nil, RequestEvidence{BoundaryEntryID: testEntryID, ToolNames: []string{}}) + testassert.Require(t, len(req.Tools) == 0, "present-empty selection became the full catalog") } func TestAcceptedPureRequestIdentitiesRecordInOrder(t *testing.T) { @@ -602,7 +673,7 @@ func TestAcceptedPureRequestIdentitiesRecordInOrder(t *testing.T) { want = append(want, HookIdentity{ExtensionID: "ext.test", HookID: "pure.change"}, HookIdentity{ExtensionID: "ext.test", HookID: "pure.restore"}) } testassert.Check(t, reflect.DeepEqual(evidence.HookIDs, want), "restored=%v contributions = %+v, want every accepted identity in order", restored, evidence.HookIDs) - testassert.Check(t, evidence.Prompt == nil && evidence.Messages == nil && evidence.ToolNames == nil, "restored=%v deltas = %+v, want none", restored, evidence) + testassert.Check(t, evidence.Prompt == capture.SystemPrompt && evidence.Messages == nil && reflect.DeepEqual(evidence.ToolNames, []string{capture.Tools[0].Name}), "restored=%v evidence = %+v, want full captured view", restored, evidence) } } @@ -1028,7 +1099,7 @@ func TestRequestHooksRunBeforeCanonicalOnlyMaintenance(t *testing.T) { committed := hookGraphHookResults(t, store, sessionID) testassert.Require(t, len(committed) == 1 && committed[0].HookID == "req.effectful.noop" && committed[0].Status == hookSucceeded, "hook results = %+v, want only the effectful no-op's durable effect", committed) - testassert.Require(t, committed[0].InputEvidence != nil && committed[0].InputEvidence.Prompt != nil && *committed[0].InputEvidence.Prompt == consumedPrompt && + testassert.Require(t, committed[0].InputEvidence != nil && committed[0].InputEvidence.Prompt == consumedPrompt && len(committed[0].InputEvidence.HookIDs) == 1 && committed[0].InputEvidence.HookIDs[0].HookID == "req.pure", "consumed evidence = %+v, want the pure intermediate prompt and identity retained at the effect boundary", committed[0].InputEvidence) adopted = *committed[0].Consequence.Prompt diff --git a/harness/context.go b/harness/context.go index 01508c20..9784d30c 100644 --- a/harness/context.go +++ b/harness/context.go @@ -29,7 +29,6 @@ var errSteeringBoundary = errors.New("harness: steering boundary") // and no later history reread re-derives it. type conversationRound struct { requestID string - canonical []model.Message // frozen canonical conversation view, prompt stripped net RequestEvidence // final effective contribution evidence of the round } @@ -42,13 +41,17 @@ type requestView struct { boundary string prompt string // effective prompt text additions []model.Message - names []string // effective visible tool names; nil means the full captured catalog + names []string // effective visible tool names contributors []HookIdentity } // newRequestView freezes the captured prompt and the canonical projection // into one live view. func newRequestView(capture ExecutionCapture, checkpoint, boundary string, canonical []model.Message) *requestView { + names := make([]string, len(capture.Tools)) + for i, tool := range capture.Tools { + names[i] = tool.Name + } return &requestView{ capture: capture, canonical: canonical, @@ -56,7 +59,7 @@ func newRequestView(capture ExecutionCapture, checkpoint, boundary string, canon boundary: boundary, prompt: capture.SystemPrompt, additions: nil, - names: nil, + names: names, } } @@ -76,9 +79,6 @@ func (v *requestView) currentCanonical() []model.Message { // effectiveTools renders the view's effective tool catalog: the captured // catalog unchanged, or its selected members in captured catalog order. func (v *requestView) effectiveTools() []model.ToolDefinition { - if v.names == nil { - return cloneToolDefinitions(v.capture.Tools) - } out := make([]model.ToolDefinition, 0, len(v.names)) for _, tool := range v.capture.Tools { for _, name := range v.names { @@ -91,23 +91,6 @@ func (v *requestView) effectiveTools() []model.ToolDefinition { return out } -// coversCaptured reports whether one already-accepted selection names every -// tool of the captured catalog. Selections are validated catalog-internal and -// canonicalized to catalog order by the accepting owner, so a selection that -// names each captured tool presents the unchanged full view. -func coversCaptured(names []string, tools []model.ToolDefinition) bool { - seen := make(map[string]bool, len(names)) - for _, name := range names { - seen[name] = true - } - for _, tool := range tools { - if !seen[tool.Name] { - return false - } - } - return true -} - // cloneToolDefinitions returns an independent owned copy of one tool // definition list with raw parameter bytes cloned. func cloneToolDefinitions(tools []model.ToolDefinition) []model.ToolDefinition { @@ -125,8 +108,7 @@ func cloneToolDefinitions(tools []model.ToolDefinition) []model.ToolDefinition { // applyConsequence transforms the live view with one already-validated // consequence: a present Prompt replaces the prompt text, Messages append to // the conversation view, and a present ToolNames replaces visibility with -// those names rebuilt from the captured catalog in catalog order. A selection -// covering the captured catalog normalizes the live view to the full catalog. +// those names in captured catalog order. func (v *requestView) applyConsequence(con RequestConsequence) { if con.Prompt != nil { v.prompt = *con.Prompt @@ -135,20 +117,7 @@ func (v *requestView) applyConsequence(con RequestConsequence) { v.additions = append(v.additions, con.Messages...) } if con.ToolNames != nil { - if coversCaptured(con.ToolNames, v.capture.Tools) { - v.names = nil - } else { - ordered := make([]string, 0, len(con.ToolNames)) - for _, tool := range v.capture.Tools { - for _, name := range con.ToolNames { - if name == tool.Name { - ordered = append(ordered, tool.Name) - break - } - } - } - v.names = ordered - } + v.names = append([]string{}, con.ToolNames...) } } @@ -162,31 +131,29 @@ func (v *requestView) recordAccepted(identity HookIdentity, con RequestConsequen v.contributors = append(v.contributors, identity) } -// evidenceFor renders one evidence value over the frozen capture baseline. -// The delta fields are baseline-relative at every consumed boundary: only -// dimensions the view actually changed are recorded. Every accepted hook -// identity renders in contribution order regardless of the deltas. +// evidenceFor renders the complete effective view and accepted hook identities. func (v *requestView) evidenceFor() RequestEvidence { evidence := RequestEvidence{ CheckpointEntryID: v.checkpoint, BoundaryEntryID: v.boundary, - } - if v.prompt != v.capture.SystemPrompt { - prompt := v.prompt - evidence.Prompt = &prompt + Prompt: v.prompt, + ToolNames: append([]string{}, v.names...), } if len(v.additions) > 0 { evidence.Messages = cloneRequestMessages(v.additions) } - if v.names != nil && !coversCaptured(v.names, v.capture.Tools) { - names := make([]string, len(v.names)) - copy(names, v.names) - evidence.ToolNames = names - } evidence.HookIDs = append([]HookIdentity(nil), v.contributors...) return evidence } +func acceptRequestEvidence(v RequestEvidence) (RequestEvidence, error) { + raw, err := encodeRequestEvidence(v) + if err != nil { + return RequestEvidence{}, err + } + return decodeRequestEvidence(raw) +} + // validateRequestConsequence is the accepting gate for one consequence at the // request boundary: the shared message rule (tool execution members excluded), // and tool names validated against and canonicalized to the captured catalog. @@ -235,21 +202,17 @@ func validateRequestConsequence(capture ExecutionCapture, con RequestConsequence } owned.ToolNames = ordered } - return owned, nil + raw, err := encodeRequestConsequence(owned) + if err != nil { + return RequestConsequence{}, err + } + return decodeRequestConsequence(raw) } -// buildDispatchRequest materializes one model dispatch request from the -// evidence adopted at the committed intent over the round's frozen canonical -// messages, never from a reread history or replayed hooks. The materialized -// view is the one the recovery path rebuilds with materializeRequestView: -// the actual request carries the captured catalog, and a narrowed selection -// (present-empty advertises none) narrows the request's tool entries, which -// are the advertisement the Engine gates on and the assistant records. -func buildDispatchRequest(capture ExecutionCapture, canonical []model.Message, evidence RequestEvidence) (model.Request, []string) { - prompt := capture.SystemPrompt - if evidence.Prompt != nil { - prompt = *evidence.Prompt - } +// buildDispatchRequest composes the accepted evidence over frozen canonical +// messages; its tool entries are the advertisement the Engine gates on. +func buildDispatchRequest(capture ExecutionCapture, canonical []model.Message, evidence RequestEvidence) model.Request { + prompt := evidence.Prompt messages := make([]model.Message, 0, 1+len(canonical)+len(evidence.Messages)) if prompt != "" { messages = append(messages, model.Message{ @@ -259,15 +222,8 @@ func buildDispatchRequest(capture ExecutionCapture, canonical []model.Message, e } messages = append(messages, canonical...) messages = append(messages, evidence.Messages...) - tools := capture.Tools - if evidence.ToolNames != nil { - tools = (&requestView{capture: capture, names: evidence.ToolNames}).effectiveTools() - } - names := make([]string, len(tools)) - for i, def := range tools { - names[i] = def.Name - } - return model.Request{Messages: messages, Tools: tools}, names + tools := (&requestView{capture: capture, names: evidence.ToolNames}).effectiveTools() + return model.Request{Messages: messages, Tools: tools} } // cloneRequestMessages deep-copies one message list at every ownership @@ -343,7 +299,10 @@ func (h *Harness) requestSource(c *coordinator, operationID string, capture Exec if err := h.runRequestHooks(ctx, c, operationID, inv, requestID, view); err != nil { return model.Request{}, err } - contributions := view.evidenceFor() // the maintenance procedure consumes the frozen contribution evidence + contributions, err := acceptRequestEvidence(view.evidenceFor()) // the maintenance procedure consumes the frozen contribution evidence + if err != nil { + return model.Request{}, err + } planInput := PlanInput{ Trigger: triggerAutomatic, Messages: cloneRequestMessages(canonical), @@ -377,17 +336,19 @@ func (h *Harness) requestSource(c *coordinator, operationID string, capture Exec if err != nil { return model.Request{}, err } - view.checkpoint, view.boundary = checkpointID, boundaryID + contributions.CheckpointEntryID, contributions.BoundaryEntryID = checkpointID, boundaryID } else { _ = closeMaintenance() // no checkpoint to precede: dispose the unused maintenance stage } // The frozen contribution view is applied exactly once to the rebuilt // projection; its references now name the new checkpoint and tail. - evidence := view.evidenceFor() - req, _ := buildDispatchRequest(capture, canonical, evidence) + evidence, err := acceptRequestEvidence(contributions) + if err != nil { + return model.Request{}, err + } + req := buildDispatchRequest(capture, canonical, evidence) *round = conversationRound{ requestID: requestID, - canonical: canonical, net: evidence, } return req, nil diff --git a/harness/effect.go b/harness/effect.go index 2c6bb1b9..5b2f34bd 100644 --- a/harness/effect.go +++ b/harness/effect.go @@ -216,9 +216,7 @@ type modelResult struct { // invocation's model function in one logical model-effect intent/result pair: // it commits the active-effect intent carrying the round's request identity, // purpose, model, and effective input evidence, dispatches the physical -// attempts against the request materialized from the committed intent's -// adopted evidence over the round's frozen canonical view (never a reread -// history or replayed hooks), invokes the assembly callback exactly once +// attempts against the Engine-accepted request, invokes the assembly callback exactly once // after a stream is accepted, derives the settlement from the assembled // output, validates it, and commits the result and complete next Operation // state before returning the committed settlement. The automatic maintenance @@ -271,16 +269,17 @@ func (h *Harness) modelEffect(c *coordinator, operationID string, exec Execution } return agent.ModelSettlement{}, cause } - // The dispatch view is materialized from the committed intent's - // adopted evidence over the round's frozen canonical messages, so - // actual I/O consumes exactly the values the durable intent owns. - dispatchReq, dispatchNames := buildDispatchRequest(capture, round.canonical, *intent.evidence) + // Derive the advertisement from the same request the Engine accepted. + dispatchNames := make([]string, len(req.Tools)) + for i, tool := range req.Tools { + dispatchNames[i] = tool.Name + } // The one shared attempt loop lives inside the committed intent: every // failed attempt is classified by the retry policy, waits run behind // the cancellation checks, and an accepted stream never re-enters // retry — assembly owns it, including closure. var output model.Output - switch res := runModelAttempts(ctx, dispatchReq, attempt, exec.Retry); res.kind { + switch res := runModelAttempts(ctx, req, attempt, exec.Retry); res.kind { case attemptAccepted: out, attemptErr := assemble(intent.expected, h.observeStream(intent.sessionID, operationID, res.stream)) // exactly one assembly after acceptance if attemptErr != nil { @@ -1315,14 +1314,10 @@ func newAssistantEntry(sessionID, operationID, entryID string, out *model.Output } // copyRequestEvidence returns an independent deep-owned copy of one evidence -// value: canonical references, the prompt pointee, every recorded message's +// value: canonical references, the prompt, every recorded message's // nested parts and extras, and the name and provenance lists. func copyRequestEvidence(v RequestEvidence) RequestEvidence { out := v - if v.Prompt != nil { - prompt := *v.Prompt - out.Prompt = &prompt - } out.Messages = cloneRequestMessages(v.Messages) if v.ToolNames != nil { names := make([]string, len(v.ToolNames)) @@ -2552,7 +2547,10 @@ func (h *Harness) runOneRequestHookPure(ctx context.Context, requestID string, v func (h *Harness) runRequestHookEffect(ctx context.Context, c *coordinator, operationID string, hook RequestHook, requestID string, view *requestView) error { // The consumed evidence the hook will actually receive is rendered from // the live view before anything durable commits. - consumed := view.evidenceFor() + consumed, err := acceptRequestEvidence(view.evidenceFor()) + if err != nil { + return err + } resultID, err := h.beginRequestHookEffect(ctx, c, operationID, requestID, hook.ID, hook.ExtensionID, consumed) if err != nil { return err @@ -2566,25 +2564,18 @@ func (h *Harness) runRequestHookEffect(ctx context.Context, c *coordinator, oper } return errExecutionInterrupted } - // The callback consumes the adopted evidence the committed intent owns, - // materialized over the round's frozen canonical context — never a reread - // projection. - committedIntent, err := h.readRequestHookIntent(c, operationID, resultID) - if err != nil { - return err - } - materialized := materializeRequestView(view.capture, view.canonical, *committedIntent.Evidence) - view.prompt, view.additions, view.names = materialized.prompt, materialized.additions, materialized.names + // The callback consumes the accepted view also published in the intent. + view.prompt, view.additions, view.names = consumed.Prompt, consumed.Messages, consumed.ToolNames input := RequestHookInput{ RequestID: requestID, CanonicalMessages: cloneRequestMessages(view.canonical), - Messages: cloneRequestMessages(materialized.currentCanonical()), - CheckpointEntryID: committedIntent.Evidence.CheckpointEntryID, - BoundaryEntryID: committedIntent.Evidence.BoundaryEntryID, + Messages: cloneRequestMessages(view.currentCanonical()), + CheckpointEntryID: consumed.CheckpointEntryID, + BoundaryEntryID: consumed.BoundaryEntryID, CapturedPrompt: view.capture.SystemPrompt, - Prompt: materialized.prompt, + Prompt: view.prompt, Catalog: cloneToolDefinitions(view.capture.Tools), - Tools: materialized.effectiveTools(), + Tools: view.effectiveTools(), } consequence, runErr := hook.Run(ctx, input) settleCtx := context.WithoutCancel(h.ctx) @@ -2630,22 +2621,6 @@ func (h *Harness) runRequestHookEffect(ctx context.Context, c *coordinator, oper return nil } -// readRequestHookIntent returns the committed request-hook effect intent of -// the given reserved identity from the coordinator's validated view. -func (h *Harness) readRequestHookIntent(c *coordinator, operationID, resultID string) (*ActiveEffect, error) { - c.mu.Lock() - defer c.mu.Unlock() - op, ok := c.graph.Operation(operationID) - if !ok { - return nil, fmt.Errorf("%w: operation %q in session %q", ErrNotFound, operationID, c.graph.Session.Identity.SessionID) - } - if op.State.ActiveEffect == nil || op.State.ActiveEffect.Kind != EffectHook || - op.State.ActiveEffect.ResultEntryID != resultID || op.State.ActiveEffect.Evidence == nil { - return nil, invalidInput("operation %q carries no committed request hook effect intent for result identity %q", operationID, resultID) - } - return ownActiveEffect(op.State.ActiveEffect), nil -} - // execute is the private agent.Run composition of one admitted execution: // after the admission it invokes the preparation's opener exactly once with // the execution context and the owned committed admission, and runs the same @@ -2960,18 +2935,3 @@ func (h *Harness) settleAgentTerminal(c *coordinator, operationID string, res ag } return runErr } - -// materializeRequestView rebuilds one effective view from the frozen capture -// and canonical messages plus one committed evidence value: the adopted -// representation the durable boundary owns, never a fresh-history reread. -func materializeRequestView(capture ExecutionCapture, canonical []model.Message, evidence RequestEvidence) *requestView { - view := newRequestView(capture, evidence.CheckpointEntryID, evidence.BoundaryEntryID, canonical) - if evidence.Prompt != nil { - view.prompt = *evidence.Prompt - } - view.additions = cloneRequestMessages(evidence.Messages) - if evidence.ToolNames != nil && !coversCaptured(evidence.ToolNames, capture.Tools) { - view.names = append([]string{}, evidence.ToolNames...) - } - return view -} diff --git a/harness/effect_test.go b/harness/effect_test.go index 12fca3a7..f49ef669 100644 --- a/harness/effect_test.go +++ b/harness/effect_test.go @@ -2105,7 +2105,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { } { got := string(committedAssistant(t, store, sessionID).Extra["form"]) - testassert.Equal(t, got, `{"n":[1,1.0,9007199254740993]}`) + testassert.Equal(t, got, `{ "n" : [1, 1.0, 9007199254740993] }`) } for i := range producer { // ownership oracle: clobber the producer's buffer after the commit producer[i] = ' ' @@ -2117,7 +2117,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{}, InvocationExecution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess, "result = %+v, want the valid original to run", got) - testassert.Equal(t, seenAtBoundary, `{"n":[1,1.0,9007199254740993]}`) + testassert.Equal(t, seenAtBoundary, `{ "n" : [1, 1.0, 9007199254740993] }`) }) t.Run("oversized diagnostic is bounded and stored once", func(t *testing.T) { @@ -2182,13 +2182,11 @@ func TestToolOutcomeMetadataRules(t *testing.T) { }{ {name: "object metadata commits", metadata: json.RawMessage(`{"kind":"editpreview","files":["a.go"]}`), stored: `{"kind":"editpreview","files":["a.go"]}`}, {name: "scalar metadata commits", metadata: json.RawMessage(`42`), stored: `42`}, - {name: "HTML characters persist in the escaped durable encoding", metadata: json.RawMessage(`"<"`), stored: `"\u003c"`}, + {name: "HTML characters persist verbatim", metadata: json.RawMessage(`"<"`), stored: `"<"`}, {name: "metadata exactly at the durable bound commits", metadata: json.RawMessage(`"` + strings.Repeat("x", maxToolMetadataBytes-2) + `"`), stored: `"` + strings.Repeat("x", maxToolMetadataBytes-2) + `"`}, {name: "durable encoding one byte over the bound is dropped and the result commits", metadata: json.RawMessage(`"` + strings.Repeat("x", maxToolMetadataBytes-1) + `"`), stored: ""}, - // The two-representation regression: the raw value is exactly at the - // bound, but HTML-escaping expands its durable encoding far past it, - // so the metadata drops while the result still commits. - {name: "raw at the bound but durable-oversized after escaping is dropped and the result commits", metadata: json.RawMessage(`"` + strings.Repeat("<", maxToolMetadataBytes-2) + `"`), stored: ""}, + // Framing overhead does not count toward the raw metadata bound. + {name: "HTML bytes exactly at the bound commit", metadata: json.RawMessage(`"` + strings.Repeat("<", maxToolMetadataBytes-2) + `"`), stored: `"` + strings.Repeat("<", maxToolMetadataBytes-2) + `"`}, {name: "malformed metadata is dropped and the result commits", metadata: json.RawMessage(`{broken`), stored: ""}, {name: "null metadata is absent", metadata: json.RawMessage(`null`), stored: ""}, {name: "empty metadata is absent", metadata: json.RawMessage{}, stored: ""}, @@ -2690,7 +2688,11 @@ func captureNextModelRequest(t *testing.T, h *Harness, c *coordinator) model.Req return completedTurnStream(), nil } { - _, err := invokeModelEffect(t, h.modelEffect(c, testOpID, effectExecution(modelFn, nil), invocationExecution(modelFn, nil), testCapture(), fixtureRound(h, c)), defaultModelRequest(t), nil) + exec, inv := effectExecution(modelFn, nil), invocationExecution(modelFn, nil) + round := &conversationRound{} + request, err := h.requestSource(c, testOpID, testCapture(), exec, inv, round)(context.Background()) + testassert.NoError(t, err) + _, err = invokeModelEffect(t, h.modelEffect(c, testOpID, exec, inv, testCapture(), round), *request.Request, nil) testassert.NoError(t, err) } return captured diff --git a/harness/graph.go b/harness/graph.go index 2c5066bb..fb07bbd3 100644 --- a/harness/graph.go +++ b/harness/graph.go @@ -1175,6 +1175,9 @@ func (v *graphValidation) collectRequestDomain(opID string) error { if err := v.validateActiveEvidenceRefs(effect.Evidence); err != nil { return err } + if err := v.validateEvidenceNames(effect.Evidence, op.Admission.Execution); err != nil { + return err + } if err := v.validateEvidenceHookOrder(effect.Evidence.HookIDs, req.orderedResults(), graphEntry{}); err != nil { return v.corrupt("active effect evidence of operation %q: %v", opID, err) } @@ -1202,6 +1205,13 @@ func (v *graphValidation) validateEvidenceRefs(what string, evidence *RequestEvi if evidence == nil { return v.corrupt("%s carries no evidence", what) } + if owner.Envelope.OperationID != "" { + if op, ok := v.opsByID[owner.Envelope.OperationID]; ok { + if err := v.validateEvidenceNames(evidence, op.Admission.Execution); err != nil { + return err + } + } + } if evidence.BoundaryEntryID == "" { return v.corrupt("%s carries no canonical boundary reference", what) } @@ -1244,6 +1254,20 @@ func (v *graphValidation) validateEvidenceRefs(what string, evidence *RequestEvi return nil } +func (v *graphValidation) validateEvidenceNames(evidence *RequestEvidence, capture ExecutionCapture) error { + next := 0 + for _, name := range evidence.ToolNames { + for next < len(capture.Tools) && capture.Tools[next].Name != name { + next++ + } + if next == len(capture.Tools) { + return v.corrupt("evidence tool names are outside captured catalog or out of order") + } + next++ + } + return nil +} + // conversationBearingEntryKind reports whether one committed entry projects // into the canonical conversation view a request consumes. func conversationBearingEntryKind(entry graphEntry) bool { diff --git a/harness/graph_test.go b/harness/graph_test.go index 34aaf668..537667bb 100644 --- a/harness/graph_test.go +++ b/harness/graph_test.go @@ -422,6 +422,131 @@ func validateFixture(t *testing.T, store *graphStorage, sessionID string) (*sess return validateSessionGraph(context.Background(), store, sessionID) } +func TestEvidenceCatalogOwnershipAndCopiedPrefix(t *testing.T) { + for _, tc := range []struct { + name string + names []string + valid bool + }{ + {"empty", []string{}, true}, + {"ordered subset", []string{"echo", "other"}, true}, + {"foreign", []string{"foreign"}, false}, + {"reordered", []string{"other", "echo"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := validTestGraph() + tool := testToolDefinition() + tool.Name = "other" + fixture.ops[0].Admission.Execution.Tools = append(fixture.ops[0].Admission.Execution.Tools, tool) + fixture.entries[1].assistant.AdvertisedNames = tc.names + fixture.entries[1].assistant.Evidence.ToolNames = tc.names + _, err := validateFixture(t, fixture.storage(t), testSessionID) + if (err == nil) != tc.valid { + t.Fatalf("validate = %v, want valid %v", err, tc.valid) + } + }) + } + for _, tc := range []struct { + name string + names []string + valid bool + }{ + {"active allowed", []string{"echo"}, true}, {"active foreign", []string{"foreign"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := validTestGraph() + fixture.entries = fixture.entries[:1] + effect, request := conversationModelEffect(hexID(9)) + effect.Evidence.ToolNames = tc.names + fixture.ops[0].State.ActiveEffect, fixture.ops[0].State.Request = effect, request + _, err := validateFixture(t, fixture.storage(t), testSessionID) + if (err == nil) != tc.valid { + t.Fatalf("validate = %v, want valid %v", err, tc.valid) + } + }) + } + fixture := validTestGraph() + fixture.session.Identity.SourceSessionID = hexID(10) + fixture.session.Identity.SourceBoundaryEntryID = hexID(11) + fixture.entries[0].env.OperationID = "" + fixture.entries[0].input.OperationID = "" + fixture.entries[1].env.OperationID = "" + assistant := fixture.entries[1].assistant + assistant.OperationID = "" + assistant.AdvertisedNames = []string{"foreign"} + assistant.Evidence.ToolNames = []string{"foreign"} + input := validInputEntry(testOpID) + input.EntryID = hexID(3) + fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(3), OperationID: testOpID, Kind: EntryInput, Sequence: 3, CommittedAt: testTime}, input: &input}) + fixture.ops[0].Admission.AdmittedEntry.EntryID = hexID(3) + _, err := validateFixture(t, fixture.storage(t), testSessionID) + if err != nil { + t.Fatalf("copied prefix with foreign historical catalog: %v", err) + } + stored, err := encodeAssistantEntry(*assistant) + testassert.NoError(t, err) + obj := wireObject(t, stored) + evidence := wireObject(t, obj["evidence"]) + evidence["tool_names"] = mustMarshal([]string{"echo"}) + obj["evidence"] = mustMarshal(evidence) + fixture.entries[1].rawOverride = mustMarshal(obj) + if _, err := validateFixture(t, fixture.storage(t), testSessionID); err == nil { + t.Fatal("stored copied advertisement mismatch accepted") + } + assistant.Evidence.ToolNames = []string{"echo"} + if _, err := encodeAssistantEntry(*assistant); err == nil { + t.Fatal("copied advertisement mismatch accepted") + } +} + +func TestEvidenceCatalogForCommittedOwners(t *testing.T) { + for _, owner := range []string{"hook", "checkpoint", "settlement"} { + for _, foreign := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/foreign=%v", owner, foreign), func(t *testing.T) { + fixture := validTestGraph() + names := []string{"echo"} + if foreign { + names = []string{"foreign"} + } + switch owner { + case "hook": + hook := validRequestBoundHookResult(hookSucceeded) + hook.EntryID, hook.RequestID = hexID(3), "request-one" + hook.InputEvidence.ToolNames = names + assistant := fixture.entries[1] + assistant.env.Sequence = 3 + assistant.assistant.RequestID = "request-one" + assistant.assistant.Evidence.HookIDs = []HookIdentity{{ExtensionID: hook.ExtensionID, HookID: hook.HookID}} + fixture.entries = []testEntry{fixture.entries[0], {env: Entry{SessionID: testSessionID, ID: hexID(3), OperationID: testOpID, Kind: EntryHookResult, Sequence: 2, CommittedAt: testTime}, hookResult: &hook}, assistant} + case "checkpoint": + comp := validCompactionEntry(testOpID) + comp.EntryID = hexID(5) + comp.Evidence.ToolNames = names + fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(5), OperationID: testOpID, Kind: EntryCompaction, Sequence: 3, CommittedAt: testTime}, compaction: &comp}) + fixture.session.State.CheckpointEntryID = hexID(5) + case "settlement": + settlement := validSettlementEntry() + settlement.EntryID = hexID(3) + settlement.RequestID = "settled-request" + settlement.Evidence = &RequestEvidence{BoundaryEntryID: hexID(1), ToolNames: names} + fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(3), OperationID: testOpID, Kind: EntryOperationSettlement, Sequence: 3, CommittedAt: testTime}, settlement: &settlement}) + stamp := testTime + fixture.ops[0].State.Status, fixture.ops[0].State.SettledAt = OperationSuccess, &stamp + fixture.ops[0].State.Terminal = &OperationTerminal{SettlementEntry: EntryRef{SessionID: testSessionID, EntryID: hexID(3)}} + fixture.session.State.CurrentOperationID = "" + } + _, err := validateFixture(t, fixture.storage(t), testSessionID) + if foreign && (err == nil || !strings.Contains(err.Error(), "evidence tool names")) { + t.Fatalf("foreign names: %v", err) + } + if !foreign && err != nil { + t.Fatalf("valid names: %v", err) + } + }) + } + } +} + // mustState extracts the state member raw bytes of an encoded session // register payload. func mustState(t *testing.T, raw json.RawMessage) json.RawMessage { diff --git a/harness/hooks_public_test.go b/harness/hooks_public_test.go index 2d54c79d..4e7caba0 100644 --- a/harness/hooks_public_test.go +++ b/harness/hooks_public_test.go @@ -108,8 +108,8 @@ func TestPublicToolArgumentHookTurn(t *testing.T) { seen := append([]string{}, hookSeen...) toolInput := append([]string{}, prepared...) mu.Unlock() - testassert.Require(t, len(seen) == 2 && seen[0] == `{"x":1}` && seen[1] == `{"x":"\u003c"}`, "hook chain received %q, want the producer's committed normalization then the first hook's committed replacement", seen) - testassert.Require(t, len(toolInput) == 1 && toolInput[0] == `{"y":"\u0026\u003e"}`, "concrete tool received %q, want the final committed replacement bytes", toolInput) + testassert.Require(t, len(seen) == 2 && seen[0] == `{"x":1}` && seen[1] == `{ "x": "<" }`, "hook chain received %q, want the producer's committed normalization then the first hook's committed replacement", seen) + testassert.Require(t, len(toolInput) == 1 && toolInput[0] == `{"y":"&>"}`, "concrete tool received %q, want the final committed replacement bytes", toolInput) rec, err := f.h.ReadOperation(ctx, session, "op-1") testassert.Require(t, err == nil && rec.State.Status == harness.OperationSuccess && rec.State.ActiveEffect == nil && len(rec.State.PendingToolCalls) == 0, "hook turn = %+v err %v, want terminal success with every effect settled", rec, err) @@ -119,11 +119,11 @@ func TestPublicToolArgumentHookTurn(t *testing.T) { entries, err := store.ReadEntries(ctx, session, 0) testassert.NoError(t, err) type hookWire struct { - HookID string `json:"hook_id"` - ToolCallID string `json:"tool_call_id"` - Status string `json:"status"` - Arguments json.RawMessage `json:"arguments"` - Error string `json:"error"` + HookID string `json:"hook_id"` + ToolCallID string `json:"tool_call_id"` + Status string `json:"status"` + Arguments []byte `json:"arguments_base64"` + Error string `json:"error"` } var hookResults []hookWire for _, entry := range entries { @@ -137,7 +137,7 @@ func TestPublicToolArgumentHookTurn(t *testing.T) { } hookResults = append(hookResults, wire) } - testassert.Require(t, len(hookResults) == 2 && hookResults[0].HookID == "cap.hook.one" && hookResults[0].Status == "success" && string(hookResults[0].Arguments) == `{"x":"\u003c"}` && hookResults[1].HookID == "cap.hook.two" && hookResults[1].Status == "success" && string(hookResults[1].Arguments) == `{"y":"\u0026\u003e"}`, "committed hook results = %+v, want one success per hook with the durable bytes", hookResults) + testassert.Require(t, len(hookResults) == 2 && hookResults[0].HookID == "cap.hook.one" && hookResults[0].Status == "success" && string(hookResults[0].Arguments) == `{ "x": "<" }` && hookResults[1].HookID == "cap.hook.two" && hookResults[1].Status == "success" && string(hookResults[1].Arguments) == `{"y":"&>"}`, "committed hook results = %+v, want one success per hook with the durable bytes", hookResults) // The evidence never projects as conversation: the completing turn's // projection carries the call result, not the hook replacements. @@ -199,14 +199,14 @@ func TestPublicToolArgumentHookTurn(t *testing.T) { continue } var wire struct { - NormalizedArguments json.RawMessage `json:"normalized_arguments"` + NormalizedArguments []byte `json:"normalized_arguments_base64"` HookIDs []struct { ExtensionID string `json:"extension_id"` HookID string `json:"hook_id"` } `json:"hook_ids"` } testassert.NoError(t, json.Unmarshal(entry.Payload, &wire)) - testassert.Require(t, string(wire.NormalizedArguments) == `{"y":"\u0026\u003e"}` && len(wire.HookIDs) == 2 && + testassert.Require(t, string(wire.NormalizedArguments) == `{"y":"&>"}` && len(wire.HookIDs) == 2 && wire.HookIDs[0].HookID == "cap.hook.one" && wire.HookIDs[1].HookID == "cap.hook.two", "copied tool result %s lost its accepted selection: %s", entry.ID, entry.Payload) acceptedSelections++ diff --git a/harness/hooks_test.go b/harness/hooks_test.go index cbc51b9a..b277f625 100644 --- a/harness/hooks_test.go +++ b/harness/hooks_test.go @@ -160,16 +160,16 @@ func TestToolArgumentHookChainSettlement(t *testing.T) { count, chain := normalizations, append([]string{}, received...) mu.Unlock() testassert.Equal(t, count, 3) - testassert.Require(t, len(chain) == 2 && chain[0] == ` {"x": 1} ` && chain[1] == `{"x":"\u003c"}`, "hook chain received %q, want the raw arguments then the committed replacement bytes", chain) + testassert.Require(t, len(chain) == 2 && chain[0] == ` {"x": 1} ` && chain[1] == `{ "x": "<" }`, "hook chain received %q, want the raw arguments then the committed replacement bytes", chain) prepared := spy.received() - testassert.Require(t, len(prepared) == 1 && prepared[0].ID == "call-1" && prepared[0].Name == "echo" && string(prepared[0].Arguments) == `{"y":"\u0026\u003e"}`, "preparer received %+v, want the final committed replacement bytes under the bound identity", prepared) + testassert.Require(t, len(prepared) == 1 && prepared[0].ID == "call-1" && prepared[0].Name == "echo" && string(prepared[0].Arguments) == `{"y":"&>"}`, "preparer received %+v, want the final committed replacement bytes under the bound identity", prepared) // The committed evidence: one success hook_result per hook, whose stored // arguments are exactly the bytes the continuation consumed. committed := hookGraphHookResults(t, store, sessionID) testassert.Equal(t, len(committed), 2) - testassert.Require(t, committed[0].HookID == "cap.hook.one" && committed[0].Status == hookSucceeded && string(committed[0].Arguments) == `{"x":"\u003c"}` && committed[0].Error == "", "first hook result = %+v, want the committed compacted replacement", committed[0]) - testassert.Require(t, committed[1].HookID == "cap.hook.two" && committed[1].Status == hookSucceeded && string(committed[1].Arguments) == `{"y":"\u0026\u003e"}`, "second hook result = %+v, want the committed escaped replacement", committed[1]) + testassert.Require(t, committed[0].HookID == "cap.hook.one" && committed[0].Status == hookSucceeded && string(committed[0].Arguments) == `{ "x": "<" }` && committed[0].Error == "", "first hook result = %+v, want the committed replacement", committed[0]) + testassert.Require(t, committed[1].HookID == "cap.hook.two" && committed[1].Status == hookSucceeded && string(committed[1].Arguments) == `{"y":"&>"}`, "second hook result = %+v, want the committed replacement", committed[1]) // The assistant entry never mutated: original raw bytes retained, the // producer's own committed normalization untouched. @@ -184,7 +184,7 @@ func TestToolArgumentHookChainSettlement(t *testing.T) { raw, derr := base64.StdEncoding.DecodeString(record.ArgumentsBase64) testassert.Require(t, derr == nil && string(raw) == ` {"x": 1} `, "assistant raw arguments = %q err %v, want the original bytes retained", raw, derr) } - testassert.Require(t, string(record.NormalizedArguments) == `{"x":1}`, "assistant normalized_arguments = %q, want the producer's committed value unchanged", record.NormalizedArguments) + testassert.Require(t, string(record.NormalizedArguments) == ` {"x": 1} `, "assistant normalized_arguments = %q, want the producer's committed value unchanged", record.NormalizedArguments) } // Hook results are execution evidence, not conversation messages. @@ -833,13 +833,13 @@ func TestRequestHookEffectfulChainSettlement(t *testing.T) { // only the net prompt setter. testassert.Require(t, len(committed[0].InputEvidence.HookIDs) == 1 && committed[0].InputEvidence.HookIDs[0].HookID == "req.pure" && - committed[0].InputEvidence.Prompt != nil && *committed[0].InputEvidence.Prompt == "pure prompt system", + committed[0].InputEvidence.Prompt == "pure prompt system", "consumed input evidence = %+v, want the retained pure intermediate and its prompt delta at the effect boundary", committed[0].InputEvidence) assistant := compositionAssistant(t, store, sessionID) testassert.Require(t, assistant.Evidence != nil && len(assistant.Evidence.HookIDs) == 2 && assistant.Evidence.HookIDs[0].HookID == "req.pure" && assistant.Evidence.HookIDs[1].HookID == "req.effectful", "assistant evidence hook identities = %+v, want the changed pure contributor and the effectful setter in hook order", assistant.Evidence.HookIDs) - testassert.Require(t, assistant.Evidence.Prompt != nil && *assistant.Evidence.Prompt == "effectful prompt" && len(assistant.Evidence.Messages) == 1, + testassert.Require(t, assistant.Evidence.Prompt == "effectful prompt" && len(assistant.Evidence.Messages) == 1, "assistant evidence = %+v, want the final effective prompt and contribution", assistant.Evidence) { if _, err := validateFixture(t, store, sessionID); err != nil { @@ -2011,11 +2011,11 @@ func TestRequestHookCodecAdoptedValueContinuesTheLiveView(t *testing.T) { } committed := hookGraphHookResults(t, store, sessionID) testassert.Require(t, len(committed) == 1 && committed[0].Status == hookSucceeded && - committed[0].InputEvidence != nil && committed[0].InputEvidence.Prompt != nil && *committed[0].InputEvidence.Prompt == effectfulSaw && + committed[0].InputEvidence != nil && committed[0].InputEvidence.Prompt == effectfulSaw && committed[0].Consequence != nil && committed[0].Consequence.Prompt != nil && *committed[0].Consequence.Prompt == effectfulSaw, "hook results = %+v, want the durable rows carrying the adopted text", committed) assistant := compositionAssistant(t, store, sessionID) - testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.Prompt != nil && *assistant.Evidence.Prompt == effectfulSaw, + testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.Prompt == effectfulSaw, "assistant evidence prompt = %v, want the adopted final effective value", assistant.Evidence.Prompt) { if _, err := validateFixture(t, store, sessionID); err != nil { @@ -2124,7 +2124,7 @@ func TestRequestHookPureContributorsFollowEffectiveChange(t *testing.T) { ) testassert.Require(t, len(ev.HookIDs) == 2 && ev.HookIDs[0].HookID == "req.pure.one" && ev.HookIDs[1].HookID == "req.pure.two", "contributions = %+v, want both appenders in hook order", ev.HookIDs) - testassert.Require(t, ev.Prompt != nil && *ev.Prompt == basePrompt+" one two" && promptOf(sent) == basePrompt+" one two", + testassert.Require(t, ev.Prompt == basePrompt+" one two" && promptOf(sent) == basePrompt+" one two", "prompt delta = %v dispatch = %q, want the whole accumulated replacement", ev.Prompt, promptOf(sent)) }) t.Run("reassertion after a change is also a contributor", func(t *testing.T) { @@ -2195,7 +2195,7 @@ func TestRequestHookPureContributorsFollowEffectiveChange(t *testing.T) { testassert.Require(t, len(ev.HookIDs) == 3 && ev.HookIDs[0].HookID == "req.pure.prompt" && ev.HookIDs[1].HookID == "req.pure.hide" && ev.HookIDs[2].HookID == "req.pure.show", "contributions = %+v, want every hook that actually changed the live view, in hook order", ev.HookIDs) - testassert.Require(t, ev.ToolNames == nil, "names delta = %v, want none: the final view is the full catalog", ev.ToolNames) + testassert.Require(t, reflect.DeepEqual(ev.ToolNames, fullNames), "names = %v, want the full catalog", ev.ToolNames) testassert.Require(t, len(sent.Tools) == len(capture.Tools), "dispatch advertises %d tools, want the restored full catalog", len(sent.Tools)) }) t.Run("hide-all then full baseline restoration keeps identities with no deltas", func(t *testing.T) { @@ -2212,7 +2212,7 @@ func TestRequestHookPureContributorsFollowEffectiveChange(t *testing.T) { ) testassert.Require(t, len(ev.HookIDs) == 2 && ev.HookIDs[0].HookID == "req.pure.hide" && ev.HookIDs[1].HookID == "req.pure.restore", "contributions = %+v, want both accepted hooks kept in configured order", ev.HookIDs) - testassert.Require(t, ev.Prompt == nil && ev.ToolNames == nil, "deltas = prompt %v names %v, want no durable delta", ev.Prompt, ev.ToolNames) + testassert.Require(t, ev.Prompt == basePrompt && reflect.DeepEqual(ev.ToolNames, fullNames), "evidence = prompt %v names %v, want full captured view", ev.Prompt, ev.ToolNames) testassert.Require(t, promptOf(sent) == basePrompt && len(sent.Tools) == len(capture.Tools), "dispatch = %q with %d tools, want the unchanged baseline view", promptOf(sent), len(sent.Tools)) }) t.Run("effectful no-op keeps the pure intermediate and its own fact", func(t *testing.T) { @@ -2238,8 +2238,8 @@ func TestRequestHookPureContributorsFollowEffectiveChange(t *testing.T) { testassert.Require(t, len(committed) == 1 && committed[0].HookID == "req.effectful.noop" && committed[0].Status == hookSucceeded && committed[0].Consequence != nil && committed[0].Consequence.Prompt != nil && *committed[0].Consequence.Prompt == basePrompt+" one", "hook results = %+v, want the no-op's own committed fact", committed) - testassert.Require(t, committed[0].InputEvidence != nil && committed[0].InputEvidence.Prompt != nil && - *committed[0].InputEvidence.Prompt == basePrompt+" one" && + testassert.Require(t, committed[0].InputEvidence != nil && + committed[0].InputEvidence.Prompt == basePrompt+" one" && len(committed[0].InputEvidence.HookIDs) == 1 && committed[0].InputEvidence.HookIDs[0].HookID == "req.pure.one", "consumed evidence = %+v, want the pure intermediate provenance at the effect boundary", committed[0].InputEvidence) assistant := compositionAssistant(t, store, sessionID) @@ -2386,7 +2386,7 @@ func TestRequestHookInputFrozenDomainsCarryHiddenCatalogAndRestoresThroughCatalo testassert.Require(t, assistant.Evidence != nil && len(assistant.Evidence.HookIDs) == 3 && assistant.Evidence.HookIDs[0].HookID == "req.first" && assistant.Evidence.HookIDs[1].HookID == "req.successor" && assistant.Evidence.HookIDs[2].HookID == "req.barrier", "assistant contributions = %+v, want every accepted hook in configured order (the no-op barrier included)", assistant.Evidence.HookIDs) - testassert.Require(t, assistant.Evidence.ToolNames == nil, "names delta = %v, want none: the final view is the full catalog", assistant.Evidence.ToolNames) + testassert.Require(t, reflect.DeepEqual(assistant.Evidence.ToolNames, fullNames), "names = %v, want the full catalog", assistant.Evidence.ToolNames) } testassert.Require(t, reflect.DeepEqual(parametersOf(capture.Tools), baseParameters), "the caller's capture must stand untouched") // the caller's capture stands untouched }) @@ -2426,7 +2426,7 @@ func TestRequestHookInputFrozenDomainsCarryHiddenCatalogAndRestoresThroughCatalo "hook results = %+v, want exactly the effectful successor's own transaction", committed) { input := committed[0].InputEvidence - testassert.Require(t, input != nil && input.Prompt != nil && *input.Prompt == basePrompt+" modified" && + testassert.Require(t, input != nil && input.Prompt == basePrompt+" modified" && input.ToolNames != nil && len(input.ToolNames) == 0 && len(input.Messages) == 1, "consumed input evidence = %+v, want the hidden-view prompt, names, and addition the effect actually received", input) testassert.Require(t, len(input.HookIDs) == 1 && input.HookIDs[0].HookID == "req.first", diff --git a/harness/payload.go b/harness/payload.go index 536570cc..4f5cfd15 100644 --- a/harness/payload.go +++ b/harness/payload.go @@ -1,6 +1,7 @@ package harness import ( + "bytes" "encoding/base64" "encoding/json" "errors" @@ -245,7 +246,24 @@ func encodeToolCallRecord(c toolCallRecord) (json.RawMessage, error) { if err := validateToolCallRecord(c); err != nil { return nil, invalidInput("tool call: %v", err) } - return json.Marshal(c) + obj := map[string]json.RawMessage{ + "id": mustMarshal(c.ID), "ordinal": mustMarshal(c.Ordinal), "name": mustMarshal(c.Name), + "arguments_base64": mustMarshal(c.ArgumentsBase64), "result_entry_id": mustMarshal(c.ResultEntryID), + } + if len(c.Extra) > 0 { + raw, err := marshalExtra(c.Extra) + if err != nil { + return nil, err + } + obj["extra"] = raw + } + if len(c.NormalizedArguments) > 0 { + obj["normalized_arguments_base64"] = frame(c.NormalizedArguments) + } + if c.NormalizationFailure != "" { + obj["normalization_failure"] = mustMarshal(c.NormalizationFailure) + } + return json.Marshal(obj) } // decodeToolCallRecord reads one assistant tool-call record with exact keys. @@ -254,7 +272,7 @@ func decodeToolCallRecord(raw json.RawMessage) (toolCallRecord, error) { if err != nil { return toolCallRecord{}, fmt.Errorf("tool call: %w", err) } - if err := rejectUnknownMembers(obj, "id", "ordinal", "name", "arguments_base64", "extra", "normalized_arguments", "normalization_failure", "result_entry_id"); err != nil { + if err := rejectUnknownMembers(obj, "id", "ordinal", "name", "arguments_base64", "extra", "normalized_arguments_base64", "normalization_failure", "result_entry_id"); err != nil { return toolCallRecord{}, err } var c toolCallRecord @@ -273,7 +291,7 @@ func decodeToolCallRecord(raw json.RawMessage) (toolCallRecord, error) { if c.Extra, err = extraMember(obj, "extra", false); err != nil { return toolCallRecord{}, err } - if c.NormalizedArguments, err = rawJSONMember(obj, "normalized_arguments", false); err != nil { + if c.NormalizedArguments, err = framedMember(obj, "normalized_arguments_base64", false); err != nil { return toolCallRecord{}, err } if c.NormalizationFailure, err = optionalNonEmptyString(obj, "normalization_failure"); err != nil { @@ -605,6 +623,14 @@ func validateAssistantEntry(v assistantEntry) error { if err := validateRequestEvidence(*v.Evidence); err != nil { return err } + if len(v.Evidence.ToolNames) != len(v.AdvertisedNames) { + return errors.New("assistant evidence does not match advertised names") + } + for i, name := range v.AdvertisedNames { + if v.Evidence.ToolNames[i] != name { + return errors.New("assistant evidence does not match advertised names") + } + } if !assistantPayloadEligible(v) { return errors.New("assistant entry carries no eligible model-visible payload (finalized content part, refusal, finalized extra, or, for completed output only, tool calls)") } @@ -635,23 +661,16 @@ func assistantPayloadEligible(v assistantEntry) bool { // plugin-owned semantics of the value. const maxToolMetadataBytes = 1 << 20 // 1 MiB -// ownedToolMetadata is the one shared metadata mechanism: it returns the -// owned bytes json.Marshal produces for one candidate tool_result metadata -// member — one complete well-formed non-null JSON value whose durable -// encoding (the compacted, HTML-escaped bytes json.Marshal produces for a -// json.RawMessage) stays within the bound. Empty, malformed, null, or -// oversized input returns nil. The bound applies to the persisted -// representation, not to the caller's raw input, and the returned buffer -// never aliases the caller. +// ownedToolMetadata clones one complete non-null JSON value within the raw +// byte bound. Empty, malformed, null, or oversized candidates are omitted. func ownedToolMetadata(raw json.RawMessage) json.RawMessage { if len(raw) == 0 { return nil } - encoded, err := json.Marshal(raw) - if err != nil || string(encoded) == "null" || len(encoded) > maxToolMetadataBytes { + if len(raw) > maxToolMetadataBytes || !json.Valid(raw) || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return nil } - return encoded + return model.CloneRaw(raw) } // encodeToolResultEntry renders one tool-result entry payload. @@ -667,9 +686,12 @@ func encodeToolResultEntry(v toolResultEntry) (json.RawMessage, error) { if err != nil { return nil, err } - var normalized json.RawMessage + var normalized, metadata json.RawMessage if len(v.NormalizedArguments) > 0 { - normalized = model.CloneRaw(v.NormalizedArguments) + normalized = frame(v.NormalizedArguments) + } + if len(v.Metadata) > 0 { + metadata = frame(v.Metadata) } wire, err := json.Marshal(struct { SessionID string `json:"session_id"` @@ -682,8 +704,8 @@ func encodeToolResultEntry(v toolResultEntry) (json.RawMessage, error) { OriginalContent string `json:"original_content"` ShapingFailure string `json:"shaping_failure,omitempty"` Evidence string `json:"evidence"` - Metadata json.RawMessage `json:"metadata,omitempty"` - NormalizedArguments json.RawMessage `json:"normalized_arguments,omitempty"` + Metadata json.RawMessage `json:"metadata_base64,omitempty"` + NormalizedArguments json.RawMessage `json:"normalized_arguments_base64,omitempty"` HookIDs json.RawMessage `json:"hook_ids,omitempty"` }{ SessionID: v.SessionID, @@ -696,7 +718,7 @@ func encodeToolResultEntry(v toolResultEntry) (json.RawMessage, error) { OriginalContent: v.OriginalContent, ShapingFailure: v.ShapingFailure, Evidence: string(v.Evidence), - Metadata: v.Metadata, + Metadata: metadata, NormalizedArguments: normalized, HookIDs: hookIDs, }) @@ -716,7 +738,7 @@ func decodeToolResultEntry(env Entry) (toolResultEntry, error) { if err != nil { return toolResultEntry{}, err } - if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "assistant_entry", "tool_call_id", "status", "content", "original_content", "shaping_failure", "evidence", "metadata", "normalized_arguments", "hook_ids"); err != nil { + if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "assistant_entry", "tool_call_id", "status", "content", "original_content", "shaping_failure", "evidence", "metadata_base64", "normalized_arguments_base64", "hook_ids"); err != nil { return toolResultEntry{}, err } sessionID, err := stringMember(obj, "session_id", true) @@ -731,7 +753,7 @@ func decodeToolResultEntry(env Entry) (toolResultEntry, error) { if err != nil { return toolResultEntry{}, err } - metadata, err := rawJSONMember(obj, "metadata", false) + metadata, err := framedMember(obj, "metadata_base64", false) if err != nil { return toolResultEntry{}, err } @@ -767,7 +789,7 @@ func decodeToolResultEntry(env Entry) (toolResultEntry, error) { if err != nil { return toolResultEntry{}, err } - normalizedArguments, err := rawJSONMember(obj, "normalized_arguments", false) + normalizedArguments, err := framedMember(obj, "normalized_arguments_base64", false) if err != nil { return toolResultEntry{}, err } @@ -878,7 +900,7 @@ func encodeHookResultEntry(v hookResultEntry) (json.RawMessage, error) { // A successful effectful execution always carries its binding-owned // consequence record. if v.ToolCallID != "" { - obj["arguments"] = model.CloneRaw(v.Arguments) + obj["arguments_base64"] = frame(v.Arguments) } else { raw, err := encodeRequestConsequence(*v.Consequence) if err != nil { @@ -913,7 +935,7 @@ func decodeHookResultEntry(env Entry) (hookResultEntry, error) { if err != nil { return hookResultEntry{}, err } - if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "hook_id", "extension_id", "tool_call_id", "request_id", "status", "evidence", "arguments", "error", "input_arguments_base64", "input_evidence", "consequence"); err != nil { + if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "hook_id", "extension_id", "tool_call_id", "request_id", "status", "evidence", "arguments_base64", "error", "input_arguments_base64", "input_evidence", "consequence"); err != nil { return hookResultEntry{}, err } sessionID, err := stringMember(obj, "session_id", true) @@ -952,7 +974,7 @@ func decodeHookResultEntry(env Entry) (hookResultEntry, error) { if err != nil { return hookResultEntry{}, err } - arguments, err := rawJSONMember(obj, "arguments", false) + arguments, err := framedMember(obj, "arguments_base64", false) if err != nil { return hookResultEntry{}, err } @@ -2729,18 +2751,22 @@ func encodeStrategyCapture(v StrategyCapture) (json.RawMessage, error) { if err := validateStrategyCapture(v); err != nil { return nil, invalidInput("strategy capture: %v", err) } + var settings json.RawMessage + if len(v.Settings) > 0 { + settings = frame(v.Settings) + } return json.Marshal(struct { ID string `json:"id"` - Settings json.RawMessage `json:"settings,omitempty"` + Settings json.RawMessage `json:"settings_base64,omitempty"` RequiresContextWindow bool `json:"requires_context_window"` - }{ID: v.ID, Settings: v.Settings, RequiresContextWindow: v.RequiresContextWindow}) + }{ID: v.ID, Settings: settings, RequiresContextWindow: v.RequiresContextWindow}) } // decodeStrategyCapture reads the resolved selected strategy input with // exact keys. Its closed relation to the captured window is checked by the // enclosing capture validation without loading any strategy code. func decodeStrategyCapture(obj map[string]json.RawMessage) (StrategyCapture, error) { - if err := rejectUnknownMembers(obj, "id", "settings", "requires_context_window"); err != nil { + if err := rejectUnknownMembers(obj, "id", "settings_base64", "requires_context_window"); err != nil { return StrategyCapture{}, err } id, err := stringMember(obj, "id", true) @@ -2756,7 +2782,11 @@ func decodeStrategyCapture(obj map[string]json.RawMessage) (StrategyCapture, err // syntax-only acceptance as the validating side: an accepted value — // including null and large numbers — keeps its original bytes, and // absence stays absent. - if settings, present := obj["settings"]; present { + if _, present := obj["settings_base64"]; present { + settings, err := framedMember(obj, "settings_base64", true) + if err != nil { + return StrategyCapture{}, err + } if !json.Valid(settings) { return StrategyCapture{}, errors.New("settings must be valid JSON") } @@ -3136,7 +3166,7 @@ func encodeActiveEffect(v ActiveEffect) (json.RawMessage, error) { obj["input_arguments_base64"] = mustMarshal("") } if len(v.FinalArguments) > 0 { - obj["final_arguments"] = model.CloneRaw(v.FinalArguments) + obj["final_arguments_base64"] = frame(v.FinalArguments) } if len(v.ContributionIDs) > 0 { raw, err := encodeHookIdentities(v.ContributionIDs) @@ -3150,7 +3180,7 @@ func encodeActiveEffect(v ActiveEffect) (json.RawMessage, error) { // decodeActiveEffect reads one active effect with exact keys. func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { - if err := rejectUnknownMembers(obj, "kind", "result_entry_id", "tool_call_id", "hook_id", "request_id", "model", "purpose", "evidence", "hook_extension_id", "input_arguments_base64", "final_arguments", "hook_ids"); err != nil { + if err := rejectUnknownMembers(obj, "kind", "result_entry_id", "tool_call_id", "hook_id", "request_id", "model", "purpose", "evidence", "hook_extension_id", "input_arguments_base64", "final_arguments_base64", "hook_ids"); err != nil { return ActiveEffect{}, err } kind, err := stringMember(obj, "kind", true) @@ -3219,7 +3249,7 @@ func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { return ActiveEffect{}, errors.New(`tool-bound hook active effect requires the "input_arguments_base64" member`) } } - finalArguments, err := rawJSONMember(obj, "final_arguments", false) + finalArguments, err := framedMember(obj, "final_arguments_base64", false) if err != nil { return ActiveEffect{}, err } @@ -3922,13 +3952,11 @@ func encodeRequestEvidence(v RequestEvidence) (json.RawMessage, error) { } obj := map[string]json.RawMessage{ "boundary_entry_id": mustMarshal(v.BoundaryEntryID), + "prompt": mustMarshal(v.Prompt), } if v.CheckpointEntryID != "" { obj["checkpoint_entry_id"] = mustMarshal(v.CheckpointEntryID) } - if v.Prompt != nil { - obj["prompt"] = mustMarshal(*v.Prompt) - } if len(v.Messages) > 0 { messages := make([]json.RawMessage, 0, len(v.Messages)) for _, m := range v.Messages { @@ -3940,9 +3968,11 @@ func encodeRequestEvidence(v RequestEvidence) (json.RawMessage, error) { } obj["messages"] = mustMarshal(messages) } - if v.ToolNames != nil { - obj["tool_names"] = mustMarshal(v.ToolNames) + names := v.ToolNames + if names == nil { + names = []string{} } + obj["tool_names"] = mustMarshal(names) if len(v.HookIDs) > 0 { raw, err := encodeHookIdentities(v.HookIDs) if err != nil { @@ -3954,8 +3984,7 @@ func encodeRequestEvidence(v RequestEvidence) (json.RawMessage, error) { } // decodeRequestEvidence reads one closed request-evidence record with exact -// keys and re-validates it. Present-empty tool_names survive as the recorded -// selection; empty messages and hook_ids arrays decode to absence. +// keys and re-validates it. Empty messages and hook_ids decode to absence. func decodeRequestEvidence(raw json.RawMessage) (RequestEvidence, error) { obj, err := decodePayloadObject(raw) if err != nil { @@ -3972,7 +4001,7 @@ func decodeRequestEvidence(raw json.RawMessage) (RequestEvidence, error) { if err != nil { return RequestEvidence{}, err } - prompt, err := optionalStringMember(obj, "prompt") + prompt, err := stringMember(obj, "prompt", true) if err != nil { return RequestEvidence{}, err } @@ -3991,6 +4020,9 @@ func decodeRequestEvidence(raw json.RawMessage) (RequestEvidence, error) { } v.Messages = append(v.Messages, m) } + if _, err := member(obj, "tool_names", true); err != nil { + return RequestEvidence{}, err + } toolNames, err := optionalStringMemberList(obj, "tool_names") if err != nil { return RequestEvidence{}, err diff --git a/harness/public_test.go b/harness/public_test.go index 272ff39f..e9130c4c 100644 --- a/harness/public_test.go +++ b/harness/public_test.go @@ -1797,29 +1797,18 @@ func TestPublicOrderedToolCallsSettle(t *testing.T) { // TestPublicToolMetadataPersistsOpaqueAcrossStores proves the durable metadata // boundary through the public surface over both storage implementations: -// a tool's metadata commits as its encoded durable bytes — even a raw value -// over the bound whose compacted encoding fits, before and after a -// restarted Harness revalidates the payloads — an executor returning -// malformed metadata still commits its result without the member, a raw -// value within the bound whose durable HTML-escaped encoding exceeds it is -// dropped while its result still commits, the model-visible projection -// carries only each committed result's content, and recovery revalidates -// both payloads. +// accepted metadata keeps exact bytes before and after recovery, malformed +// and oversized candidates are omitted, and exact-bound metadata survives. func TestPublicToolMetadataPersistsOpaqueAcrossStores(t *testing.T) { eachStore(t, func(t *testing.T, store harness.Storage) { - // the accepted executor returns the formatted fixture prefixed with - // 1 MiB of ordinary spaces: raw over the bound, but its compacted, - // HTML-escaped durable encoding is the short literal below it. const formatted = `{ "n" : [1, 1.0, 1e0, 9007199254740993], "s" : "<>&" }` - const encoded = `{"n":[1,1.0,1e0,9007199254740993],"s":"\u003c\u003e\u0026"}` rawOverBound := json.RawMessage(strings.Repeat(" ", 1<<20) + formatted) - // exactly 1 MiB of raw bytes, but every '<' encodes as \u003c in the - // durable payload, expanding it far past the bound. + // HTML-sensitive bytes are measured before base64 framing. const durableBound = 1 << 20 expanded := json.RawMessage(`"` + strings.Repeat("<", durableBound-2) + `"`) testassert.Equal(t, len(expanded), durableBound) script := newScriptModel( - publicTurn("call-1", "call-2", "call-3"), + publicTurn("call-1", "call-2", "call-3", "call-4"), publicTurn(), ) f := newPublicFixture(t, store, script, nil) @@ -1834,16 +1823,18 @@ func TestPublicToolMetadataPersistsOpaqueAcrossStores(t *testing.T) { Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "no preview"}, Metadata: json.RawMessage(`{broken`), }} - case "call-3": // durable-oversized after escaping: dropped, no transaction failure + case "call-3": // exactly at the raw byte bound return harness.PreparedTool{Permissions: publicPermission, Immediate: &harness.ToolOutcome{ Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "expanded metadata"}, Metadata: expanded, }} + case "call-4": + return harness.PreparedTool{Permissions: publicPermission, Immediate: &harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "oversized metadata"}, Metadata: rawOverBound}} } return harness.PreparedTool{Permissions: publicPermission, Execute: func(context.Context) harness.ToolOutcome { return harness.ToolOutcome{ Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "ran call-1"}, - Metadata: rawOverBound, + Metadata: json.RawMessage(formatted), } }} }), nil @@ -1859,7 +1850,7 @@ func TestPublicToolMetadataPersistsOpaqueAcrossStores(t *testing.T) { <-script.arrived // the completion turn: all three results projected before it { got := texts(script.seen()[1]) - testassert.Require(t, len(got) == 6 && got[3] == "ran call-1" && got[4] == "no preview" && got[5] == "expanded metadata", "projection = %v, want the committed contents only", got) + testassert.Require(t, len(got) == 7 && got[3] == "ran call-1" && got[4] == "no preview" && got[5] == "expanded metadata" && got[6] == "oversized metadata", "projection = %v, want the committed contents only", got) } { err := converge(t, f) @@ -1887,17 +1878,21 @@ func TestPublicToolMetadataPersistsOpaqueAcrossStores(t *testing.T) { return results } results := readResults() - testassert.Require(t, string(results["call-1"]["metadata"]) == encoded, "call-1 metadata = %s, want the committed encoded bytes verbatim", results["call-1"]["metadata"]) + var decoded []byte + testassert.NoError(t, json.Unmarshal(results["call-1"]["metadata_base64"], &decoded)) + testassert.Require(t, string(decoded) == formatted, "call-1 metadata = %s, want original bytes", decoded) { - _, present := results["call-2"]["metadata"] - testassert.Require(t, !present, "call-2 carries malformed metadata %s, want it dropped while the result committed", results["call-2"]["metadata"]) + _, present := results["call-2"]["metadata_base64"] + testassert.Require(t, !present, "call-2 carries malformed metadata %s, want it dropped while the result committed", results["call-2"]["metadata_base64"]) } testassert.Require(t, string(results["call-2"]["content"]) == `"no preview"`, "call-2 payload = %s, want the settled result alongside the dropped metadata", results["call-2"]["content"]) { - _, present := results["call-3"]["metadata"] - testassert.Require(t, !present, "call-3 carries durable-oversized metadata, want it dropped while the result committed") + testassert.NoError(t, json.Unmarshal(results["call-3"]["metadata_base64"], &decoded)) + testassert.Require(t, bytes.Equal(decoded, expanded), "call-3 metadata must preserve exact bytes at the bound") } testassert.Require(t, string(results["call-3"]["content"]) == `"expanded metadata"`, "call-3 payload = %s, want the settled result committed past the metadata drop", results["call-3"]["content"]) + _, present := results["call-4"]["metadata_base64"] + testassert.Require(t, !present && string(results["call-4"]["content"]) == `"oversized metadata"`, "oversized metadata must be omitted without losing its result") // a restarted Harness revalidates every committed payload: the durable // states prove no transaction failed and the Session stayed sound, @@ -1907,7 +1902,8 @@ func TestPublicToolMetadataPersistsOpaqueAcrossStores(t *testing.T) { testassert.NoError(t, err) } results = readResults() - testassert.Require(t, string(results["call-1"]["metadata"]) == encoded, "call-1 metadata after Recover = %s, want the committed encoded bytes verbatim", results["call-1"]["metadata"]) + testassert.NoError(t, json.Unmarshal(results["call-1"]["metadata_base64"], &decoded)) + testassert.Require(t, string(decoded) == formatted, "call-1 metadata after Recover = %s, want original bytes", decoded) }) } @@ -1982,8 +1978,8 @@ func TestPublicPreparedPermissionBoundarySurvivesRestart(t *testing.T) { switch entry.Kind { case harness.EntryAssistant: var calls []struct { - ID string `json:"id"` - NormalizedArguments json.RawMessage `json:"normalized_arguments"` + ID string `json:"id"` + NormalizedArguments []byte `json:"normalized_arguments_base64"` } { err := json.Unmarshal(wire["tool_calls"], &calls) @@ -2008,7 +2004,7 @@ func TestPublicPreparedPermissionBoundarySurvivesRestart(t *testing.T) { denial++ testassert.Require(t, status == "denied" && string(wire["content"]) == `"Permission denied."`, "call-2 result = %s, want the fixed public denial", entry.Payload) { - _, present := wire["metadata"] + _, present := wire["metadata_base64"] testassert.Require(t, !present, "denied call-2 carries a metadata member") } } else if status != "success" { @@ -3270,7 +3266,7 @@ func insertRawEntry(t *testing.T, store harness.Storage, sessionID, id, operatio } // rawToolDefinition is the one tool definition every recovery fixture captures. -const rawToolDefinition = `{"name":"echo","description":"echoes","parameters":{"type":"object"}}` +const rawToolDefinition = `{"name":"echo","description":"echoes","parameters_base64":"eyJ0eXBlIjoib2JqZWN0In0="}` // rawSessionRegister builds one open root Session register payload with empty // usage; currentOp is omitted when empty. @@ -3320,7 +3316,7 @@ func rawAssistantEntryPayload(sessionID, entryID, operationID, boundaryID string } return fmt.Sprintf( `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q}}`, + `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, sessionID, entryID, operationID, strings.Join(parts, ","), "request-a-"+entryID, boundaryID) } @@ -3349,10 +3345,10 @@ func seedRunningOperationShape(t *testing.T, store harness.Storage, activeEffect switch activeEffect { case "model": modelReserved = newRawSessionID(t) - activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"request-live","model":{"provider":"prov","model":"gpt-x"},"purpose":"conversation","evidence":{"boundary_entry_id":%q}}`, modelReserved, entryID) + activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"request-live","model":{"provider":"prov","model":"gpt-x"},"purpose":"conversation","evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]}}`, modelReserved, entryID) requestJSON = fmt.Sprintf(`,"running_request":{"request_id":"request-live","boundary_entry_id":%q,"hook_results":[]}`, entryID) case "tool": - activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"tool","result_entry_id":%q,"tool_call_id":%q,"final_arguments":{"x":1}}`, reserved[0], calls[0]) + activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"tool","result_entry_id":%q,"tool_call_id":%q,"final_arguments_base64":"eyJ4IjoxfQ=="}`, reserved[0], calls[0]) case "hook": modelReserved = newRawSessionID(t) // the hook effect's own reserved result identity activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"hook","result_entry_id":%q,"tool_call_id":%q,"hook_id":"cap.hook.one","hook_extension_id":"ext.raw","input_arguments_base64":""}`, modelReserved, calls[0]) @@ -3739,7 +3735,7 @@ func rawSignalPayload(sessionID, entryID, operationID string) string { // for one hook execution of the given call. func rawHookResultPayload(sessionID, entryID, operationID, hookID, callID string) string { return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":%q,"hook_id":%q,"extension_id":"ext.raw","tool_call_id":%q,"status":"success","evidence":"observed","input_arguments_base64":"eyJ4IjoxfQ==","arguments":{"x":1}}`, + `{"session_id":%q,"entry_id":%q,"operation_id":%q,"hook_id":%q,"extension_id":"ext.raw","tool_call_id":%q,"status":"success","evidence":"observed","input_arguments_base64":"eyJ4IjoxfQ==","arguments_base64":"eyJ4IjoxfQ=="}`, sessionID, entryID, operationID, hookID, callID) } @@ -3808,7 +3804,7 @@ func rawCopiedAssistantPayload(sessionID, entryID, boundaryID string, calls [][2 } return fmt.Sprintf( `{"session_id":%q,"entry_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q}}`, + `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, sessionID, entryID, strings.Join(parts, ","), "request-a-"+entryID, boundaryID) } @@ -4554,11 +4550,11 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { input := snapEntryOf(snap, harness.EntryInput) hookEntry := newRawSessionID(t) snapAppendEntry(snap, sessionID, hookEntry, "op-1", harness.EntryHookResult, fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":"op-1","hook_id":"cap.hook.one","extension_id":"ext.raw","request_id":"request-corrupt","status":"success","evidence":"observed","input_evidence":{"boundary_entry_id":%q},"consequence":{}}`, + `{"session_id":%q,"entry_id":%q,"operation_id":"op-1","hook_id":"cap.hook.one","extension_id":"ext.raw","request_id":"request-corrupt","status":"success","evidence":"observed","input_evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]},"consequence":{}}`, sessionID, hookEntry, input.ID)) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"hook","result_entry_id":%q,"hook_id":"cap.hook.two","hook_extension_id":"ext.raw2","request_id":"request-corrupt","evidence":{"boundary_entry_id":%q}}`, newRawSessionID(t), input.ID)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"hook","result_entry_id":%q,"hook_id":"cap.hook.two","hook_extension_id":"ext.raw2","request_id":"request-corrupt","evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]}}`, newRawSessionID(t), input.ID)) state["running_request"] = json.RawMessage(fmt.Sprintf(`{"request_id":"request-corrupt","boundary_entry_id":%q,"hook_results":[{"session_id":%q,"entry_id":%q}]}`, input.ID, sessionID, hookEntry)) }) }, @@ -4597,7 +4593,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { }) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"hook","result_entry_id":%q,"hook_id":"cap.hook.one","hook_extension_id":"ext.raw","request_id":"request-corrupt","evidence":{"boundary_entry_id":%q,"checkpoint_entry_id":%q}}`, newRawSessionID(t), input.ID, comp)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"hook","result_entry_id":%q,"hook_id":"cap.hook.one","hook_extension_id":"ext.raw","request_id":"request-corrupt","evidence":{"boundary_entry_id":%q,"checkpoint_entry_id":%q,"prompt":"system","tool_names":["echo"]}}`, newRawSessionID(t), input.ID, comp)) state["running_request"] = json.RawMessage(fmt.Sprintf(`{"request_id":"request-corrupt","checkpoint_entry_id":%q,"boundary_entry_id":%q}`, comp, input.ID)) }) }, @@ -4613,7 +4609,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { rawHookResultPayload(sessionID, hook, "op-1", "cap.hook.one", "call-1")) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"tool","result_entry_id":%q,"tool_call_id":"call-1","final_arguments":{"x":1}}`, snapReservedResultID(t, snap))) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"tool","result_entry_id":%q,"tool_call_id":"call-1","final_arguments_base64":"eyJ4IjoxfQ=="}`, snapReservedResultID(t, snap))) }) }, }, @@ -4889,6 +4885,43 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { } } + for _, mutation := range []struct { + name, key string + value json.RawMessage + detail string + }{ + {"missing prompt", "prompt", nil, `required member "prompt" is missing`}, + {"null names", "tool_names", json.RawMessage(`null`), `member "tool_names" must not be null`}, + } { + rows = append(rows, corruptSiblingRow{ + name: "assistant evidence: " + mutation.name, + seed: seedCorruptSiblingPendingCall, + detail: mutation.detail, + mutate: func(t *testing.T, snap *sessionSnapshot) { + entry := snapEntryOf(snap, harness.EntryAssistant) + entry.Payload = editPayloadObject(t, entry.Payload, func(obj map[string]json.RawMessage) { + obj["evidence"] = editPayloadObject(t, obj["evidence"], func(ev map[string]json.RawMessage) { + if mutation.value == nil { + delete(ev, mutation.key) + } else { + ev[mutation.key] = mutation.value + } + }) + }) + }, + }) + } + rows = append(rows, corruptSiblingRow{ + name: "assistant evidence: foreign catalog name", seed: seedCorruptSiblingPendingCall, + detail: "evidence tool names are outside captured catalog", + mutate: func(t *testing.T, snap *sessionSnapshot) { + entry := snapEntryOf(snap, harness.EntryAssistant) + entry.Payload = editPayloadObject(t, entry.Payload, func(obj map[string]json.RawMessage) { + obj["advertised_names"] = json.RawMessage(`["foreign"]`) + obj["evidence"] = editPayloadObject(t, obj["evidence"], func(ev map[string]json.RawMessage) { ev["tool_names"] = json.RawMessage(`["foreign"]`) }) + }) + }, + }) // The tool_result metadata member inventory: persisted values that // violate the durable member rules — a null member (the schema-valid // representation of malformed metadata: absence encodes by omission, @@ -4908,7 +4941,11 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { mutate: func(t *testing.T, snap *sessionSnapshot) { entry := snapEntryOf(snap, harness.EntryToolResult) entry.Payload = editPayloadObject(t, entry.Payload, func(obj map[string]json.RawMessage) { - obj["metadata"] = mutation.metadata + if mutation.name == "null member" { + obj["metadata_base64"] = mutation.metadata + } else { + obj["metadata_base64"], _ = json.Marshal(base64.StdEncoding.EncodeToString(mutation.metadata)) + } }) }, }) @@ -4933,7 +4970,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { entry := snapEntryOf(snap, harness.EntryAssistant) entry.Payload = editPayloadObject(t, entry.Payload, func(obj map[string]json.RawMessage) { obj["tool_calls"] = editJSONArray(t, obj["tool_calls"], func(items []map[string]json.RawMessage) { - items[0]["normalized_arguments"] = mutation.wrong + items[0]["normalized_arguments_base64"], _ = json.Marshal(base64.StdEncoding.EncodeToString(mutation.wrong)) }) }) }, @@ -4954,7 +4991,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { input := snapEntryOf(snap, harness.EntryInput) hook := newRawSessionID(t) snapAppendEntry(snap, sessionID, hook, "op-1", harness.EntryHookResult, fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":"op-1","hook_id":"cap.hook.one","extension_id":"ext.raw","request_id":"request-corrupt","status":"success","evidence":"observed","input_evidence":{"boundary_entry_id":%q},"consequence":{},"input_arguments_base64":""}`, + `{"session_id":%q,"entry_id":%q,"operation_id":"op-1","hook_id":"cap.hook.one","extension_id":"ext.raw","request_id":"request-corrupt","status":"success","evidence":"observed","input_evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]},"consequence":{},"input_arguments_base64":""}`, sessionID, hook, input.ID)) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { @@ -4970,7 +5007,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { input := snapEntryOf(snap, harness.EntryInput) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"hook","result_entry_id":%q,"hook_id":"cap.hook.one","hook_extension_id":"ext.raw","request_id":"request-corrupt","evidence":{"boundary_entry_id":%q},"input_arguments_base64":""}`, newRawSessionID(t), input.ID)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"hook","result_entry_id":%q,"hook_id":"cap.hook.one","hook_extension_id":"ext.raw","request_id":"request-corrupt","evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]},"input_arguments_base64":""}`, newRawSessionID(t), input.ID)) state["running_request"] = json.RawMessage(fmt.Sprintf(`{"request_id":"request-corrupt","boundary_entry_id":%q}`, input.ID)) }) }, @@ -4983,7 +5020,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { input := snapEntryOf(snap, harness.EntryInput) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q,"request_id":"request-corrupt","model":{"provider":"prov","model":"m"},"purpose":"conversation","evidence":{"boundary_entry_id":%q},"input_arguments_base64":""}`, newRawSessionID(t), input.ID)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q,"request_id":"request-corrupt","model":{"provider":"prov","model":"m"},"purpose":"conversation","evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]},"input_arguments_base64":""}`, newRawSessionID(t), input.ID)) state["running_request"] = json.RawMessage(fmt.Sprintf(`{"request_id":"request-corrupt","boundary_entry_id":%q}`, input.ID)) }) }, @@ -5006,7 +5043,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { mutate: func(t *testing.T, snap *sessionSnapshot) { reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"tool","result_entry_id":%q,"tool_call_id":"call-1","final_arguments":{},"input_arguments_base64":""}`, snapReservedResultID(t, snap))) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"tool","result_entry_id":%q,"tool_call_id":"call-1","final_arguments_base64":"e30=","input_arguments_base64":""}`, snapReservedResultID(t, snap))) }) }, }, @@ -5413,9 +5450,9 @@ func TestPublicForkCopiesPrefixAndAdmits(t *testing.T) { testassert.Equal(t, copiedInput.Origin, "user") var copiedAssistant struct { ToolCalls []struct { - ID string `json:"id"` - ResultEntryID string `json:"result_entry_id"` - NormalizedArguments json.RawMessage `json:"normalized_arguments"` + ID string `json:"id"` + ResultEntryID string `json:"result_entry_id"` + NormalizedArguments []byte `json:"normalized_arguments_base64"` } `json:"tool_calls"` Usage *struct{} `json:"usage"` } @@ -5431,14 +5468,14 @@ func TestPublicForkCopiesPrefixAndAdmits(t *testing.T) { SessionID string `json:"session_id"` EntryID string `json:"entry_id"` } `json:"assistant_entry"` - ToolCallID string `json:"tool_call_id"` - Metadata json.RawMessage `json:"metadata"` + ToolCallID string `json:"tool_call_id"` + Metadata []byte `json:"metadata_base64"` } { err := json.Unmarshal(entries[2].Payload, &copiedResult) testassert.NoError(t, err) } - testassert.Require(t, copiedResult.AssistantEntry.SessionID == dest && copiedResult.AssistantEntry.EntryID == entries[1].ID && copiedResult.ToolCallID == "call-1" && string(copiedResult.Metadata) == `{"n":[1,1.0,1e0,9007199254740993],"s":"\u003c\u003e\u0026"}`, "copied result reference = %+v, want it rewritten inside the copied prefix; copied result metadata = %s, want the source's encoded metadata preserved verbatim", copiedResult.AssistantEntry, copiedResult.Metadata) + testassert.Require(t, copiedResult.AssistantEntry.SessionID == dest && copiedResult.AssistantEntry.EntryID == entries[1].ID && copiedResult.ToolCallID == "call-1" && string(copiedResult.Metadata) == `{ "n" : [1, 1.0, 1e0, 9007199254740993], "s" : "<>&" }`, "copied result reference = %+v, want it rewritten inside the copied prefix; copied result metadata = %s, want the source metadata preserved verbatim", copiedResult.AssistantEntry, copiedResult.Metadata) var copiedSignal struct { RelatedOperation struct { SessionID string `json:"session_id"` @@ -6574,7 +6611,7 @@ func rawCompactionEntryPayload(sessionID, entryID, operationID, summary, boundar `"messages":[{"role":"assistant","source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}]}],`+ `"boundary_entry_id":%q,"configuration_revision":"rev-1",`+ `"facts":[{"request_id":%q,"model":{"provider":"prov","model":"gpt-x"},"input":[{"role":"user","content":[{"kind":"text","text":"transcript seed"}]}]}],`+ - `"consumed_request_id":"request-seed","evidence":{"boundary_entry_id":%q}}`, + `"consumed_request_id":"request-seed","evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":["echo"]}}`, sessionID, entryID, operationID, "[Previous conversation summary]\n\n"+summary+"\n\n[End of summary. Continue from here.]", boundaryID, "maintainer-"+entryID, boundaryID) } @@ -6591,7 +6628,7 @@ func rawSettledSuccessOperation(t *testing.T, store harness.Storage, sessionID, sessionID, inputID, operationID, inputText)) insertRawEntry(t, store, sessionID, assistantID, operationID, harness.EntryAssistant, fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q}}`, + `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, sessionID, assistantID, operationID, assistantText, "request-a-"+assistantID, inputID)) insertRawEntry(t, store, sessionID, settlementID, operationID, harness.EntryOperationSettlement, fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"success"}`, sessionID, settlementID, operationID)) diff --git a/harness/schema.go b/harness/schema.go index 067a910f..7cbf9b95 100644 --- a/harness/schema.go +++ b/harness/schema.go @@ -109,16 +109,15 @@ type HookIdentity struct { // RequestEvidence is the one closed request-evidence value: the canonical // boundary/checkpoint references of the projection actually consumed at that -// boundary, the effective prompt/context/tool-view deltas against the frozen -// capture (absent members mean unchanged), and the contributing hook -// identities in contribution order. It records changes plus canonical input -// references, never a copy of unchanged conversation history. +// boundary, concrete effective prompt and tool names, request-only additions, +// and contributing hook identities in order. Unchanged history is referenced, +// never copied. type RequestEvidence struct { CheckpointEntryID string `json:"checkpoint_entry_id,omitempty"` BoundaryEntryID string `json:"boundary_entry_id"` - Prompt *string `json:"prompt,omitempty"` + Prompt string `json:"prompt"` Messages []model.Message `json:"messages,omitempty"` - ToolNames []string `json:"tool_names,omitempty"` + ToolNames []string `json:"tool_names"` HookIDs []HookIdentity `json:"hook_ids,omitempty"` } diff --git a/harness/snapshot_public_test.go b/harness/snapshot_public_test.go index ffdb1f91..2cac04e5 100644 --- a/harness/snapshot_public_test.go +++ b/harness/snapshot_public_test.go @@ -204,8 +204,7 @@ func TestPublicSnapshotSessionReadsAndOwns(t *testing.T) { assistant := pristineFacts[1].Assistant testassert.Require(t, assistant != nil && len(assistant.ToolCalls) == 1 && assistant.ToolCalls[0].ID == "call-1" && assistant.ToolCalls[0].ResultEntryID != "", "assistant fact = %+v", assistant) result := pristineFacts[2].ToolResult - // the durable codec's JSON encoding escapes the raw metadata's markup - testassert.Require(t, result != nil && result.ToolCallID == "call-1" && string(result.Metadata) == `{"n":[1,1.0],"s":"\u003c\u003e\u0026"}`, "tool result fact = %+v", result) + testassert.Require(t, result != nil && result.ToolCallID == "call-1" && string(result.Metadata) == `{"n":[1,1.0],"s":"<>&"}`, "tool result fact = %+v", result) for i, seq := range []int64{1, 2, 3, 4, 5, 6, 7, 8} { testassert.Require(t, pristineFacts[i].Sequence == seq, "fact[%d].Sequence = %d, want ascending %d", i, pristineFacts[i].Sequence, seq) } diff --git a/harness/snapshot_test.go b/harness/snapshot_test.go index 4d62ecb3..f90b8504 100644 --- a/harness/snapshot_test.go +++ b/harness/snapshot_test.go @@ -77,6 +77,7 @@ func factFixtureGraph() *testGraph { assistant1 := validAssistantEntry(testOpID) assistant1.EntryID = hexID(2) assistant1.AdvertisedNames = []string{"echo"} + assistant1.Evidence.ToolNames = []string{"echo"} assistant1.Extra = model.Extra{"reasoning": json.RawMessage(`{"deep":[1]}`)} call := validToolCallRecord() call.ResultEntryID = testResultID diff --git a/harness/strategy_contract_test.go b/harness/strategy_contract_test.go index e11ddff8..2426d71d 100644 --- a/harness/strategy_contract_test.go +++ b/harness/strategy_contract_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/MMinasyan/lightcode/agent" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" ) @@ -1152,6 +1153,7 @@ func TestMaintenanceRequestMessageDomain(t *testing.T) { }{ {"empty request", nil}, {"historical tool exchange", exchange}, + {"opaque bytes and typed text", []model.Message{{Role: model.RoleUser, Content: []model.ContentPart{{Kind: model.PartText, Text: string([]byte{'a', 0xff, 'b'}), Extra: model.Extra{"raw": json.RawMessage(` { "n" : 1e999, "s" : "<" } `)}}}}}}, } outcomes := []string{"checkpoint", "failure", "none-success"} for _, shape := range shapes { @@ -1159,7 +1161,9 @@ func TestMaintenanceRequestMessageDomain(t *testing.T) { t.Run(shape.name+" on "+outcome, func(t *testing.T) { h, store, c, sessionID := newEffectHarness(t, nil) capture := captureForHandoff() + var sent model.Request exec := compactionExecution(t, func(ctx context.Context, req model.Request) (model.Stream, error) { + sent = req return streamOf(turnDelta("stop", "one pass"), usageDelta(model.Usage{InputTokens: 4, OutputTokens: 2})), nil }, nil) exec.Strategy = fixtureStrategyBinding(requestShapeStrategy{request: shape.request, outcome: outcome}) @@ -1191,8 +1195,13 @@ func TestMaintenanceRequestMessageDomain(t *testing.T) { published = settlementEntryOf(t, graph).Facts } testassert.Require(t, len(published) == 1, "published facts = %+v, want the one effect fact, empty input included", published) + testassert.Require(t, reflect.DeepEqual(sent.Messages, published[0].Input) || len(sent.Messages) == 0 && len(published[0].Input) == 0, "physical request and stored fact differ: %+v / %+v", sent.Messages, published[0].Input) testassert.Require(t, len(published[0].Input) == len(shape.request), "fact input = %+v, want the actual request messages", published[0].Input) for i := range shape.request { + if shape.name == "opaque bytes and typed text" { + testassert.Require(t, published[0].Input[i].TextContent() == "a\ufffdb" && string(published[0].Input[i].Content[0].Extra["raw"]) == ` { "n" : 1e999, "s" : "<" } `, "accepted input = %+v", published[0].Input[i]) + continue + } testassert.Require(t, published[0].Input[i].TextContent() == shape.request[i].TextContent() && published[0].Input[i].Role == shape.request[i].Role, "fact input[%d] = %+v, want %+v", i, published[0].Input[i], shape.request[i]) } @@ -1206,6 +1215,90 @@ func TestMaintenanceRequestMessageDomain(t *testing.T) { } } +func TestMaintenanceMalformedRequestAfterOpeningCancellation(t *testing.T) { + for _, manual := range []bool{false, true} { + for _, cancelOnOpen := range []bool{false, true} { + t.Run(fmt.Sprintf("manual=%v/cancel=%v", manual, cancelOnOpen), func(t *testing.T) { + var h *Harness + var store *graphStorage + var c *coordinator + var sessionID string + capture := captureForHandoff() + operationID := testOpID + if manual { + fixture := validTestGraph() + stamp := testTime + fixture.ops[0].State.Status, fixture.ops[0].State.SettledAt = OperationSuccess, &stamp + fixture.ops[0].State.Terminal = &OperationTerminal{SettlementEntry: EntryRef{SessionID: testSessionID, EntryID: hexID(3)}} + settlement := validSettlementEntry() + settlement.EntryID = hexID(3) + fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(3), OperationID: testOpID, Kind: EntryOperationSettlement, Sequence: 3, CommittedAt: testTime}, settlement: &settlement}) + compact := validOperationRecord() + compact.Admission.OperationID, compact.Admission.RequestKind, compact.Admission.AdmittedEntry = "compact-op", RequestKindCompact, EntryRef{} + compact.Admission.Execution = capture + fixture.ops = append(fixture.ops, compact) + fixture.session.State.CurrentOperationID = "compact-op" + store = fixture.storage(t) + h = newTestHarness(t, store, nil) + sessionID, operationID = testSessionID, "compact-op" + c = leaseCoordinator(t, h, sessionID) + } else { + h, store, c, sessionID = newEffectHarness(t, nil) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + dispatches, closes := 0, 0 + modelFn := func(context.Context, model.Request) (model.Stream, error) { + dispatches++ + return completedTurnStream(), nil + } + exec := compactionExecution(t, modelFn, nil) + original := exec.OpenInvocation + exec.OpenInvocation = func(ctx context.Context, request InvocationRequest) (InvocationExecution, error) { + inv, err := original(ctx, request) + inv.Close = func() error { closes++; return nil } + if cancelOnOpen { + cancel() + } + return inv, err + } + exec.Strategy = fixtureStrategyBinding(requestShapeStrategy{request: []model.Message{{Role: "bad"}}}) + frozen, checkpoint, boundary, err := h.projectCanonical(c, operationID) + testassert.NoError(t, err) + trigger := triggerAutomatic + suspended := &RunningRequest{RequestID: "request-conversation", BoundaryEntryID: boundary} + if manual { + trigger, suspended = triggerManual, nil + } + _, _, closeInvocation, err := h.runContextMaintenance(ctx, c, operationID, exec, capture, PlanInput{Trigger: trigger, Messages: frozen, CheckpointEntryID: checkpoint, BoundaryEntryID: boundary}, suspended) + _ = closeInvocation() + if cancelOnOpen { + if err != errExecutionInterrupted { + t.Fatalf("canceled opener = %v, want interruption", err) + } + } else { + var protocol *agent.ProtocolError + if !errors.As(err, &protocol) { + t.Fatalf("live malformed request = %v, want Agent protocol error", err) + } + } + if dispatches != 0 || closes != 1 { + t.Fatalf("dispatches=%d closes=%d", dispatches, closes) + } + graph, err := validateFixture(t, store, sessionID) + testassert.NoError(t, err) + want := OperationFailure + if cancelOnOpen { + want = OperationInterruption + } + if got := operationStateOf(t, graph, operationID).Status; got != want { + t.Fatalf("status = %s, want %s", got, want) + } + }) + } + } +} + // repeatedCallIDFixture builds the fixture Session whose frozen projection // carries two authentic completed tool exchanges from different Operations // sharing the same opaque call ID: the admitted input, assistant and result diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 02a1e2e7..a2162259 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -30,10 +30,9 @@ const ( // sqliteSchemaVersion is the only accepted schema version; it is stamped // into PRAGMA user_version at initialization and checked at open. Version - // 13 is the operation-state unreported_maintenance payload member and the - // removal of the captured model-pricing cache_write rate; the SQL tables - // are unchanged. - sqliteSchemaVersion = 13 + // 14 frames opaque payload bytes and records concrete request evidence; + // the SQL tables are unchanged. + sqliteSchemaVersion = 14 // sqlitePoolDSN configures every physical connection of the long-lived // pool: write-ahead logging, full synchronous durability, immediate diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 6dbe8897..39fcb65a 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -186,7 +186,7 @@ func sqliteFixtureDatabase(t *testing.T, path string, ddl []string, version int) } // TestSQLiteSchemaInitialization proves a missing path is initialized with the -// complete canonical schema in one shot: version 13, exactly the two canonical +// complete canonical schema in one shot: version 14, exactly the two canonical // tables and the one explicit index, only the autoindexes implied by those // tables, a well-formed image, no sidecars after close, and no partial file // when initialization fails. @@ -202,7 +202,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var version int { err := conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version) - testassert.Require(t, err == nil && version == 13, "PRAGMA user_version = %d (error %v), want 13", version, err) + testassert.Require(t, err == nil && version == 14, "PRAGMA user_version = %d (error %v), want 14", version, err) } objects := map[string]string{} @@ -263,7 +263,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var emptyVersion int { err := emptyStore.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&emptyVersion) - testassert.Check(t, err == nil && emptyVersion == 13, "zero-length file user_version = %d (error %v), want 13", emptyVersion, err) + testassert.Check(t, err == nil && emptyVersion == 14, "zero-length file user_version = %d (error %v), want 14", emptyVersion, err) } testassert.NoError(t, emptyStore.Close()) sqliteAssertNoSidecars(t, empty) @@ -276,7 +276,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var reservedVersion int { err := reservedStore.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&reservedVersion) - testassert.Check(t, err == nil && reservedVersion == 13, "reserved-path user_version = %d (error %v), want 13", reservedVersion, err) + testassert.Check(t, err == nil && reservedVersion == 14, "reserved-path user_version = %d (error %v), want 14", reservedVersion, err) } testassert.NoError(t, reservedStore.Close()) { @@ -395,7 +395,7 @@ func TestSQLiteRejectsNonEmptyVersionZero(t *testing.T) { testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError testassert.Require(t, errors.As(err, &incompatible), "error = %T, want *harness.IncompatibleSchemaError", err) - testassert.Check(t, incompatible.Found == 0 && incompatible.Supported == 13, "recovered %+v, want found 0 supported 13", incompatible) + testassert.Check(t, incompatible.Found == 0 && incompatible.Supported == 14, "recovered %+v, want found 0 supported 14", incompatible) testassert.Check(t, store == nil, "rejected database returned a store") { after := sqliteFileDigest(t, path) @@ -405,19 +405,19 @@ func TestSQLiteRejectsNonEmptyVersionZero(t *testing.T) { } // TestSQLiteRejectsPriorVersion proves a database left at the prior schema -// version 12 — the canonical tables unchanged, only the payload schema +// version 13 — the canonical tables unchanged, only the payload schema // older — is rejected through the read-only open phase with the incompatible // class before recovery ever sees the store, leaving the file byte-identical // with no sidecars. func TestSQLiteRejectsPriorVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "prior.db") - sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 12) + sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 13) before := sqliteFileDigest(t, path) store, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 12 && incompatible.Supported == 13, "recovered %+v, want found 12 supported 13", incompatible) + testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 13 && incompatible.Supported == 14, "recovered %+v, want found 13 supported 14", incompatible) testassert.Check(t, store == nil, "rejected prior-version database returned a store") { after := sqliteFileDigest(t, path) @@ -427,25 +427,25 @@ func TestSQLiteRejectsPriorVersion(t *testing.T) { } // TestSQLiteRejectsUnsupportedVersion proves a database with a future schema -// version beyond the accepted 13 is rejected without any file or sidecar +// version beyond the accepted 14 is rejected without any file or sidecar // change. func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") - sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 14) + sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 15) before := sqliteFileDigest(t, path) _, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 14 && incompatible.Supported == 13, "recovered %+v, want found 14 supported 13", incompatible) + testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 15 && incompatible.Supported == 14, "recovered %+v, want found 15 supported 14", incompatible) { after := sqliteFileDigest(t, path) - testassert.Check(t, after == before, "rejected version-14 database file changed") + testassert.Check(t, after == before, "rejected version-15 database file changed") } sqliteAssertNoSidecars(t, path) - // Unsupported hot-WAL sibling: version 14 lives only in an uncheckpointed - // WAL beside a checkpointed version 13 main file, with no SHM file — the + // Unsupported hot-WAL sibling: version 15 lives only in an uncheckpointed + // WAL beside a checkpointed version 14 main file, with no SHM file — the // crashed-writer state. Read-only validation must see the WAL-carried // version and reject the copy while leaving the database and WAL bytes // unchanged; a SQLite-owned -shm WAL index may appear and remain. @@ -453,7 +453,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { live, err := sql.Open(sqliteDriverName, sqliteDSN(source, "_journal_mode=WAL&_synchronous=FULL")) testassert.NoError(t, err) { - _, err := live.Exec(`PRAGMA user_version = 13`) + _, err := live.Exec(`PRAGMA user_version = 14`) testassert.NoError(t, err) } { @@ -461,7 +461,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { testassert.NoError(t, err) } { - _, err := live.Exec(`PRAGMA user_version = 14`) + _, err := live.Exec(`PRAGMA user_version = 15`) testassert.NoError(t, err) } mainBytes, err := os.ReadFile(source) @@ -478,7 +478,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { crashedStore, err := OpenSQLite(target) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var walIncompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &walIncompatible) && walIncompatible.Found == 14 && walIncompatible.Supported == 13, "recovered %+v, want the WAL-carried version found 14 supported 13", walIncompatible) + testassert.Check(t, errors.As(err, &walIncompatible) && walIncompatible.Found == 15 && walIncompatible.Supported == 14, "recovered %+v, want the WAL-carried version found 15 supported 14", walIncompatible) testassert.Check(t, crashedStore == nil, "rejected WAL-mode database returned a store") { after := sqliteFileDigest(t, target) @@ -520,12 +520,12 @@ ON registers(operation_id) WHERE kind = 'operation'` } { t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "schema.db") - sqliteFixtureDatabase(t, path, ddl, 13) + sqliteFixtureDatabase(t, path, ddl, 14) store, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError testassert.Require(t, errors.As(err, &incompatible), "error = %T, want *harness.IncompatibleSchemaError", err) - testassert.Check(t, incompatible.Found == 13 && incompatible.Supported == 13, "recovered %+v, want found 13 supported 13", incompatible) + testassert.Check(t, incompatible.Found == 14 && incompatible.Supported == 14, "recovered %+v, want found 14 supported 14", incompatible) testassert.Check(t, store == nil, "rejected database returned a store") }) } @@ -585,7 +585,7 @@ func TestSQLitePersistenceAcrossCloseReopen(t *testing.T) { sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL, - `PRAGMA user_version = 13`, + `PRAGMA user_version = 14`, `INSERT INTO registers (session_id, kind, operation_id, revision, payload) VALUES ('s1', 'session', '', 1, '{"session":"s1"}')`, `INSERT INTO entries (session_id, sequence, entry_id, operation_id, kind, committed_at_ns, payload) VALUES ('s1', 1, 'e1', '', 'input', 1700000000000000000, '{"entry":"e1"}')`, } { diff --git a/runtime/background_lifecycle_test.go b/runtime/background_lifecycle_test.go index 6899e0d7..cd4e64ce 100644 --- a/runtime/background_lifecycle_test.go +++ b/runtime/background_lifecycle_test.go @@ -289,7 +289,7 @@ func corruptSessionRegister(t *testing.T, store harness.Storage, sessionID strin // --- raw durable-state seeders --- -const lifecycleSeedToolDefinition = `{"name":"echo","description":"echoes","parameters":{"type":"object"}}` +const lifecycleSeedToolDefinition = `{"name":"echo","description":"echoes","parameters_base64":"eyJ0eXBlIjoib2JqZWN0In0="}` // lifecycleInsertRegister inserts one register payload directly through the // storage contract. diff --git a/runtime/compact_lifecycle_test.go b/runtime/compact_lifecycle_test.go index c0865711..985d2f4c 100644 --- a/runtime/compact_lifecycle_test.go +++ b/runtime/compact_lifecycle_test.go @@ -307,7 +307,7 @@ func compactRawMessageAdmission(sessionID, operationID, entryID string) string { model: usageWireRef{Provider: "prov", Model: "m"}, outputReserve: 1024, systemPrompt: "compact-sys", - tools: `[{"name":"noop","description":"tool noop","parameters":{"type":"object"}}]`, + tools: `[{"name":"noop","description":"tool noop","parameters_base64":"eyJ0eXBlIjoib2JqZWN0In0="}]`, compactModel: usageWireRef{Provider: "prov", Model: "m"}, compactPrompt: "summarize", }) @@ -320,7 +320,7 @@ func compactRawOperationRegister(admission, state string) string { func compactRawRunningModelState(now, reserved, boundary string) string { return fmt.Sprintf( `{"status":"running","started_at":%q,`+ - `"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"request-live-%s","model":{"provider":"prov","model":"m"},"purpose":"conversation","evidence":{"boundary_entry_id":%q}},`+ + `"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"request-live-%s","model":{"provider":"prov","model":"m"},"purpose":"conversation","evidence":{"boundary_entry_id":%q,"prompt":"compact-sys","tool_names":[]}},`+ `"running_request":{"request_id":"request-live-%s","boundary_entry_id":%q,"hook_results":[]},`+ `"pending_tool_calls":[],"usage":{"by_model":[]}}`, now, reserved, reserved, boundary, reserved, boundary) @@ -341,7 +341,7 @@ func compactRawInputEntry(sessionID, entryID, operationID, origin, text string) func compactRawAssistantEntry(sessionID, entryID, operationID, boundaryID, text string) string { return fmt.Sprintf( `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q}}`, + `"source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"compact-sys","tool_names":[]}}`, sessionID, entryID, operationID, text, "request-a-"+entryID, boundaryID) } @@ -357,7 +357,7 @@ func compactRawCompactionEntry(sessionID, entryID, operationID, summary, boundar `"messages":[{"role":"assistant","source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}]}],`+ `"boundary_entry_id":%q,"configuration_revision":"rev-1",`+ `"facts":[{"request_id":%q,"model":{"provider":"prov","model":"m"},"input":[{"role":"user","content":[{"kind":"text","text":"transcript seed"}]}]}],`+ - `"consumed_request_id":%q,"evidence":{"boundary_entry_id":%q}}`, + `"consumed_request_id":%q,"evidence":{"boundary_entry_id":%q,"prompt":"compact-sys","tool_names":[]}}`, sessionID, entryID, operationID, "[Previous conversation summary]\n\n"+summary+"\n\n[End of summary. Continue from here.]", boundaryID, "maintainer-"+entryID, "request-"+operationID, boundaryID) } diff --git a/runtime/composed_tools_harness_test.go b/runtime/composed_tools_harness_test.go index f9dbbed0..a7c6debe 100644 --- a/runtime/composed_tools_harness_test.go +++ b/runtime/composed_tools_harness_test.go @@ -220,7 +220,7 @@ func (th *taskHarness) readToolResults(sessionID string) map[string]model.ToolRe ToolCallID string `json:"tool_call_id"` Status model.ToolResultStatus `json:"status"` Content string `json:"content"` - Metadata json.RawMessage `json:"metadata"` + Metadata []byte `json:"metadata_base64"` } if err := json.Unmarshal(entry.Payload, &wire); err != nil { th.t.Fatalf("decode tool_result payload: %v", err) diff --git a/runtime/preparation_test.go b/runtime/preparation_test.go index a2e92494..c5b08254 100644 --- a/runtime/preparation_test.go +++ b/runtime/preparation_test.go @@ -1493,8 +1493,8 @@ func TestPreparationForwardsCapturedPolicyAndNormalizer(t *testing.T) { switch entry.Kind { case harness.EntryAssistant: var calls []struct { - ID string `json:"id"` - NormalizedArguments json.RawMessage `json:"normalized_arguments"` + ID string `json:"id"` + NormalizedArguments []byte `json:"normalized_arguments_base64"` } testassert.NoError(t, json.Unmarshal(obj["tool_calls"], &calls)) if len(calls) == 0 { @@ -1506,7 +1506,7 @@ func TestPreparationForwardsCapturedPolicyAndNormalizer(t *testing.T) { case harness.EntryToolResult: testassert.Require(t, string(obj["status"]) == `"denied"` && string(obj["content"]) == `"Permission denied."`, "tool result = %s, want the fixed denial", entry.Payload) { - _, present := obj["metadata"] + _, present := obj["metadata_base64"] testassert.Require(t, !present, "denied result carries a metadata member") } denial = true @@ -1564,11 +1564,11 @@ func TestPreparationForwardsToolArgumentHooksAcrossScopes(t *testing.T) { entries, err := store.ReadEntries(context.Background(), session, 0) testassert.NoError(t, err) type hookWire struct { - HookID string `json:"hook_id"` - ExtensionID string `json:"extension_id"` - ToolCallID string `json:"tool_call_id"` - Status string `json:"status"` - Arguments json.RawMessage `json:"arguments"` + HookID string `json:"hook_id"` + ExtensionID string `json:"extension_id"` + ToolCallID string `json:"tool_call_id"` + Status string `json:"status"` + Arguments []byte `json:"arguments_base64"` } var hookResults []hookWire for _, entry := range entries { diff --git a/runtime/production_composition_test.go b/runtime/production_composition_test.go index 2f261e17..9796f3b6 100644 --- a/runtime/production_composition_test.go +++ b/runtime/production_composition_test.go @@ -276,7 +276,7 @@ func chainToolResults(t *testing.T, store harness.Storage, sessionID string) map ToolCallID string `json:"tool_call_id"` Status model.ToolResultStatus `json:"status"` Content string `json:"content"` - Metadata json.RawMessage `json:"metadata"` + Metadata []byte `json:"metadata_base64"` } testassert.NoError(t, json.Unmarshal(entry.Payload, &wire)) results[wire.ToolCallID] = struct { diff --git a/runtime/protocol_paging_test.go b/runtime/protocol_paging_test.go index bfe3fc79..1665aeb0 100644 --- a/runtime/protocol_paging_test.go +++ b/runtime/protocol_paging_test.go @@ -422,7 +422,7 @@ func usageSeedMessageOperation(t *testing.T, store harness.Storage, sessionID, o Content: json.RawMessage(`[{"kind":"text","text":"answer ` + operationID + `"}]`), ToolCalls: []harness.ToolCallRecord{}, Usage: assistantUsage, - RequestID: "request-a-" + assistantID, Advertised: []string{}, Evidence: json.RawMessage(`{"boundary_entry_id":"` + inputID + `"}`), + RequestID: "request-a-" + assistantID, Advertised: []string{}, Evidence: json.RawMessage(`{"boundary_entry_id":"` + inputID + `","prompt":"compact-sys","tool_names":[]}`), }) testassert.NoError(t, err) settlementExtra := "" diff --git a/runtime/tools_composed_test.go b/runtime/tools_composed_test.go index 916c9a05..d30ac91e 100644 --- a/runtime/tools_composed_test.go +++ b/runtime/tools_composed_test.go @@ -612,7 +612,7 @@ func (th *toolsHarness) readToolResults(sessionID string) map[string]model.ToolR ToolCallID string `json:"tool_call_id"` Status model.ToolResultStatus `json:"status"` Content string `json:"content"` - Metadata json.RawMessage `json:"metadata"` + Metadata []byte `json:"metadata_base64"` } if err := json.Unmarshal(entry.Payload, &wire); err != nil { th.t.Fatalf("decode tool_result payload: %v", err) From 04b50e525e28b161a903e4f7b7c710159176e797 Mon Sep 17 00:00:00 2001 From: MMinasyan Date: Mon, 2 Nov 2026 22:11:21 +0400 Subject: [PATCH 2/2] refactor(harness): keep one effective advertisement --- harness/codec_test.go | 71 +++++++++++-------- harness/composition_test.go | 48 ++++++++++++- harness/context.go | 8 +-- harness/effect.go | 23 +++--- harness/graph_test.go | 48 +++++++++---- harness/hooks_test.go | 5 +- harness/payload.go | 114 +++++++++++------------------- harness/public_test.go | 7 +- harness/schema.go | 29 ++++---- harness/snapshot.go | 3 - harness/snapshot_test.go | 1 - harness/strategy_review_test.go | 12 +++- runtime/compact_lifecycle_test.go | 2 +- runtime/protocol_paging_test.go | 3 +- 14 files changed, 208 insertions(+), 166 deletions(-) diff --git a/harness/codec_test.go b/harness/codec_test.go index 907d29c9..70ad298a 100644 --- a/harness/codec_test.go +++ b/harness/codec_test.go @@ -118,16 +118,15 @@ func validToolCallRecord() toolCallRecord { func validAssistantEntry(operationID string) assistantEntry { return assistantEntry{ - SessionID: testSessionID, - EntryID: testEntryID, - OperationID: operationID, - Status: model.OutputCompleted, - Source: testModelRef(), - Content: []model.ContentPart{model.ContentPart{Kind: model.PartText, Text: "hi"}}, - ToolCalls: []toolCallRecord{}, - RequestID: "request-one", - AdvertisedNames: []string{}, - Evidence: &RequestEvidence{BoundaryEntryID: hexID(1)}, + SessionID: testSessionID, + EntryID: testEntryID, + OperationID: operationID, + Status: model.OutputCompleted, + Source: testModelRef(), + Content: []model.ContentPart{model.ContentPart{Kind: model.PartText, Text: "hi"}}, + ToolCalls: []toolCallRecord{}, + RequestID: "request-one", + Evidence: &RequestEvidence{BoundaryEntryID: hexID(1)}, } } @@ -1001,7 +1000,6 @@ func TestUsageAccounting(t *testing.T) { // while a non-null JSON value round-trips verbatim. func TestToolCallNormalizedArgumentsNullRejected(t *testing.T) { entry := validAssistantEntry(testOpID) - entry.AdvertisedNames = []string{"echo"} entry.Evidence.ToolNames = []string{"echo"} call := validToolCallRecord() call.NormalizedArguments = json.RawMessage("null") @@ -1032,7 +1030,6 @@ func TestToolCallNormalizedArgumentsNullRejected(t *testing.T) { func TestToolCallNormalizedArgumentsObjectOnly(t *testing.T) { const wrong = `[1,2]` entry := validAssistantEntry(testOpID) - entry.AdvertisedNames = []string{"echo"} entry.Evidence.ToolNames = []string{"echo"} call := validToolCallRecord() call.NormalizedArguments = json.RawMessage(wrong) @@ -1045,7 +1042,6 @@ func TestToolCallNormalizedArgumentsObjectOnly(t *testing.T) { // decode side: rewrite one valid encoded payload's member to the wrong // kind and prove decode rejects it. valid := validAssistantEntry(testOpID) - valid.AdvertisedNames = []string{"echo"} valid.Evidence.ToolNames = []string{"echo"} vcall := validToolCallRecord() vcall.NormalizedArguments = json.RawMessage(`{"x":1}`) @@ -1247,7 +1243,6 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { // One bounded diagnostic round-trips verbatim and encodes by omission // when absent. advertised := validAssistantEntry(testOpID) - advertised.AdvertisedNames = []string{"echo"} advertised.Evidence.ToolNames = []string{"echo"} failed := validToolCallRecord() failed.NormalizationFailure = diag @@ -1291,7 +1286,6 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { v := validAssistantEntry(testOpID) - v.AdvertisedNames = tc.advertised v.Evidence.ToolNames = tc.advertised v.ToolCalls = []toolCallRecord{tc.call} { @@ -1305,7 +1299,6 @@ func TestToolCallNormalizationFailureRules(t *testing.T) { // rejected by both codec sides; the exact bound decodes. t.Run("bounded diagnostic", func(t *testing.T) { v := validAssistantEntry(testOpID) - v.AdvertisedNames = []string{"echo"} v.Evidence.ToolNames = []string{"echo"} call := validToolCallRecord() call.NormalizationFailure = strings.Repeat("x", maxToolDiagnosticBytes+1) @@ -1759,7 +1752,6 @@ func TestAdoptedFormsAcrossInsertAndFork(t *testing.T) { failed.NormalizationFailure = "arguments must be one non-null JSON object" v := validAssistantEntry(testOpID) v.EntryID = entryID - v.AdvertisedNames = []string{"echo"} v.Evidence.ToolNames = []string{"echo"} v.Content = []model.ContentPart{ {Kind: model.PartText, Text: invalidText(), Extra: model.Extra{"raw": freshRaw(verbatimExtra), "utf8": invalidRawString()}}, @@ -2830,8 +2822,7 @@ func TestHookResultKnowledgeAndBindingDomains(t *testing.T) { // TestAssistantEntryRequestEvidenceRequiredMembers proves the assistant // payload's required ownership members on both codec sides: the producing -// request identity, the advertised view (present-empty included; null and -// absence reject), and the final effective request evidence with its required +// request identity and the final effective request evidence with its required // canonical boundary; decoded values stay deep-owned from the payload bytes. func TestAssistantEntryRequestEvidenceRequiredMembers(t *testing.T) { raw, err := encodeAssistantEntry(validAssistantEntry(testOpID)) @@ -2839,18 +2830,14 @@ func TestAssistantEntryRequestEvidenceRequiredMembers(t *testing.T) { env := Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: raw} decoded, err := decodeAssistantEntry(env) testassert.NoError(t, err) - testassert.Require(t, decoded.RequestID != "" && decoded.AdvertisedNames != nil && len(decoded.AdvertisedNames) == 0 && - decoded.Evidence != nil && decoded.Evidence.BoundaryEntryID == hexID(1), "decoded assistant = %+v, want the request identity, the present-empty view, and the consumed evidence", decoded) - for _, name := range []string{"request_id", "advertised_names", "evidence"} { + testassert.Require(t, decoded.RequestID != "" && decoded.Evidence != nil && decoded.Evidence.ToolNames != nil && len(decoded.Evidence.ToolNames) == 0 && + decoded.Evidence.BoundaryEntryID == hexID(1), "decoded assistant = %+v, want the request identity, the present-empty view, and the consumed evidence", decoded) + for _, name := range []string{"request_id", "evidence"} { { _, derr := decodeAssistantEntry(Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: setKey(raw, name, nil)}) testassert.Require(t, derr != nil, "absence of %s must reject", name) } } - { - _, derr := decodeAssistantEntry(Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: setKey(raw, "advertised_names", json.RawMessage(`null`))}) - testassert.Require(t, derr != nil, "a null advertised_names must reject") - } { _, derr := decodeAssistantEntry(Entry{SessionID: testSessionID, ID: testEntryID, OperationID: testOpID, Kind: EntryAssistant, Payload: setKey(raw, "evidence", json.RawMessage(`{"checkpoint_entry_id":""}`))}) testassert.Require(t, derr != nil, "evidence without its required canonical boundary must reject") @@ -2875,6 +2862,34 @@ func TestAssistantEntryRequestEvidenceRequiredMembers(t *testing.T) { testassert.Require(t, again.Evidence.BoundaryEntryID == hexID(1) && again.Evidence.Prompt == "", "payload bytes changed by a decoded mutation") } +func TestAssistantEvidenceIsStoredAdvertisement(t *testing.T) { + for _, operationID := range []string{testOpID, ""} { + v := validAssistantEntry(operationID) + v.Evidence.ToolNames = []string{"echo"} + call := validToolCallRecord() + call.NormalizedArguments = json.RawMessage(`{"x":1}`) + v.ToolCalls = []toolCallRecord{call} + raw, err := encodeAssistantEntry(v) + testassert.NoError(t, err) + if _, exists := wireObject(t, raw)["advertised_names"]; exists { + t.Fatal("assistant encoded a second advertisement") + } + env := Entry{SessionID: testSessionID, ID: testEntryID, OperationID: operationID, Kind: EntryAssistant, Payload: raw} + decoded, err := decodeAssistantEntry(env) + if err != nil { + t.Errorf("operation %q: valid evidence-only assistant refused: %v", operationID, err) + continue + } + if !reflect.DeepEqual(decoded.Evidence.ToolNames, []string{"echo"}) || string(decoded.ToolCalls[0].NormalizedArguments) != `{"x":1}` { + t.Fatalf("decoded = %+v", decoded) + } + env.Payload = setKey(raw, "advertised_names", json.RawMessage(`["echo"]`)) + if _, err := decodeAssistantEntry(env); err == nil { + t.Fatal("obsolete advertisement member accepted") + } + } +} + // TestRequestEvidenceContributionMessageDomain proves the shared message gate // at the evidence boundary: a contribution carrying every permitted shared // message field round-trips byte-faithfully through the evidence codec, and @@ -3247,10 +3262,6 @@ func TestRequestEvidenceRequiresConcretePromptAndNames(t *testing.T) { } } assistant := validAssistantEntry(testOpID) - assistant.AdvertisedNames = []string{"echo"} - if _, err := encodeAssistantEntry(assistant); err == nil { - t.Fatal("mismatched advertisement accepted") - } assistant.Evidence.ToolNames = []string{"echo"} if _, err := encodeAssistantEntry(assistant); err != nil { t.Fatal(err) diff --git a/harness/composition_test.go b/harness/composition_test.go index e54bdcc0..ecd9f74a 100644 --- a/harness/composition_test.go +++ b/harness/composition_test.go @@ -534,7 +534,7 @@ func TestModelAttemptDeepOwnedRequestCopies(t *testing.T) { testassert.Require(t, len(seen) == 2 && seen[0] == seen[1], "attempt views differ after mutation: %v", seen) assistant := compositionAssistant(t, store, sessionID) testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.BoundaryEntryID != "", "assistant evidence = %+v, want the untouched logical request evidence", assistant.Evidence) - testassert.Require(t, string(assistant.Evidence.Messages[0].Content[0].Extra["raw"]) == ` { "x" : "<" } ` && reflect.DeepEqual(assistant.AdvertisedNames, []string{"echo"}), "mutation reached evidence or advertisement") + testassert.Require(t, string(assistant.Evidence.Messages[0].Content[0].Extra["raw"]) == ` { "x" : "<" } ` && reflect.DeepEqual(assistant.Evidence.ToolNames, []string{"echo"}), "mutation reached evidence or advertisement") { if _, err := validateFixture(t, store, sessionID); err != nil { t.Fatalf("graph validation: %v", err) @@ -607,6 +607,44 @@ func TestPhysicalDispatchMatchesSourceRequest(t *testing.T) { } } +func TestUnadvertisedCallSettlesUnavailable(t *testing.T) { + h, store, c, sessionID := newEffectHarness(t, nil) + modelCalls, normalizeCalls, prepareCalls := 0, 0, 0 + modelFn := func(context.Context, model.Request) (model.Stream, error) { + modelCalls++ + if modelCalls == 1 { + return completedTurnStream(model.ToolCall{ID: "ghost-call", Name: "ghost", Arguments: json.RawMessage(`{"x":1}`)}), nil + } + return completedTurnStream(), nil + } + normalize := func(call model.ToolCall) (json.RawMessage, error) { normalizeCalls++; return objectNormalize(call) } + prepare := func(context.Context, model.ToolCall) PreparedTool { prepareCalls++; return PreparedTool{} } + exec := effectExecution(modelFn, prepare) + inv := InvocationExecution{Model: modelFn, NormalizeTool: normalize, Tool: prepare} + round := &conversationRound{} + result, err := agent.Run(context.Background(), agent.Invocation{ + ExpectedModel: testCapture().Model, + Request: h.requestSource(c, testOpID, testCapture(), exec, inv, round), + ModelEffect: h.modelEffect(c, testOpID, exec, inv, testCapture(), round), + ToolEffect: h.toolEffect(c, testOpID, exec, inv, testCapture()), + }) + testassert.NoError(t, err) + testassert.Require(t, result.Status == agent.TerminalSuccess && modelCalls == 2 && normalizeCalls == 0 && prepareCalls == 0, "result=%+v model=%d normalize=%d prepare=%d", result, modelCalls, normalizeCalls, prepareCalls) + graph, err := validateFixture(t, store, sessionID) + testassert.NoError(t, err) + var call *toolCallRecord + var toolResult *toolResultEntry + for _, entry := range graph.Entries { + if entry.Assistant != nil && len(entry.Assistant.ToolCalls) != 0 { + call = &entry.Assistant.ToolCalls[0] + } + if entry.ToolResult != nil { + toolResult = entry.ToolResult + } + } + testassert.Require(t, call != nil && len(call.NormalizedArguments) == 0 && call.NormalizationFailure == "" && toolResult != nil && toolResult.Status == model.ResultError && toolResult.Content == `Tool "ghost" is not available.`, "call=%+v result=%+v", call, toolResult) +} + // TestRequestHookConsequenceToolMembersRejectBeforeDispatch proves the shared // message rule at the hook consequence gate: a consequence carrying tool // execution members rejects before any view advance, the pure chain commits @@ -1111,20 +1149,28 @@ func TestRequestHooksRunBeforeCanonicalOnlyMaintenance(t *testing.T) { graph, err := validateFixture(t, store, sessionID) testassert.NoError(t, err) hookSeq, compactionSeq, assistantSeq := -1, -1, -1 + var checkpoint *contextCheckpointEntry + var answering *assistantEntry for _, entry := range graph.Entries { switch { case entry.HookResult != nil && entry.HookResult.RequestID != "": hookSeq = int(entry.Envelope.Sequence) case entry.Compaction != nil: compactionSeq = int(entry.Envelope.Sequence) + checkpoint = entry.Compaction case entry.Assistant != nil: if int(entry.Envelope.Sequence) > assistantSeq { assistantSeq = int(entry.Envelope.Sequence) // the round's answering assistant is the last + answering = entry.Assistant } } } testassert.Require(t, hookSeq >= 0 && compactionSeq >= 0 && assistantSeq >= 0 && hookSeq < compactionSeq && compactionSeq < assistantSeq, "committed sequences = hook %d checkpoint %d assistant %d, want hooks before the checkpoint before the assistant", hookSeq, compactionSeq, assistantSeq) + testassert.Require(t, checkpoint != nil && checkpoint.Evidence != nil && answering != nil && answering.Evidence != nil, "missing checkpoint or assistant evidence") + advanced := copyRequestEvidence(*checkpoint.Evidence) + advanced.CheckpointEntryID, advanced.BoundaryEntryID = checkpoint.EntryID, checkpoint.EntryID + testassert.Require(t, reflect.DeepEqual(advanced, *answering.Evidence), "advanced contribution = %+v, want assistant evidence %+v", advanced, *answering.Evidence) { if _, err := validateFixture(t, store, sessionID); err != nil { t.Fatalf("graph validation: %v", err) diff --git a/harness/context.go b/harness/context.go index 9784d30c..3534562a 100644 --- a/harness/context.go +++ b/harness/context.go @@ -342,14 +342,10 @@ func (h *Harness) requestSource(c *coordinator, operationID string, capture Exec } // The frozen contribution view is applied exactly once to the rebuilt // projection; its references now name the new checkpoint and tail. - evidence, err := acceptRequestEvidence(contributions) - if err != nil { - return model.Request{}, err - } - req := buildDispatchRequest(capture, canonical, evidence) + req := buildDispatchRequest(capture, canonical, contributions) *round = conversationRound{ requestID: requestID, - net: evidence, + net: contributions, } return req, nil } diff --git a/harness/effect.go b/harness/effect.go index 5b2f34bd..fbbb89c0 100644 --- a/harness/effect.go +++ b/harness/effect.go @@ -1233,10 +1233,9 @@ var errMalformedNormalization = errors.New("malformed normalization") // newAssistantEntry builds one assistant entry from one validated model // output under the given entry identity, reserving one result identity per -// completed call and recording the producing request identity, the -// effective advertised names, and the request evidence the model effect -// consumed. An output without an eligible model-visible payload writes no -// assistant entry and returns its reported usage for the consuming settlement +// completed call and recording the producing request identity and the request +// evidence the model effect consumed. An output without an eligible model-visible +// payload writes no assistant entry and returns its reported usage for the consuming settlement // entry instead. Before the entry commits, every completed call whose name is // in the producing request's ordered advertised names is normalized through // the execution's pure callback exactly once: its successful owned object @@ -1250,16 +1249,12 @@ func newAssistantEntry(sessionID, operationID, entryID string, out *model.Output return nil, nil, nil } entry := &assistantEntry{ - SessionID: sessionID, - EntryID: entryID, - OperationID: operationID, - Status: out.Status, - Source: out.Source, - RequestID: requestID, - AdvertisedNames: append([]string(nil), advertised...), - } - if entry.AdvertisedNames == nil { - entry.AdvertisedNames = []string{} + SessionID: sessionID, + EntryID: entryID, + OperationID: operationID, + Status: out.Status, + Source: out.Source, + RequestID: requestID, } if evidence != nil { owned := copyRequestEvidence(*evidence) diff --git a/harness/graph_test.go b/harness/graph_test.go index 537667bb..2277bfd4 100644 --- a/harness/graph_test.go +++ b/harness/graph_test.go @@ -438,7 +438,6 @@ func TestEvidenceCatalogOwnershipAndCopiedPrefix(t *testing.T) { tool := testToolDefinition() tool.Name = "other" fixture.ops[0].Admission.Execution.Tools = append(fixture.ops[0].Admission.Execution.Tools, tool) - fixture.entries[1].assistant.AdvertisedNames = tc.names fixture.entries[1].assistant.Evidence.ToolNames = tc.names _, err := validateFixture(t, fixture.storage(t), testSessionID) if (err == nil) != tc.valid { @@ -473,11 +472,17 @@ func TestEvidenceCatalogOwnershipAndCopiedPrefix(t *testing.T) { fixture.entries[1].env.OperationID = "" assistant := fixture.entries[1].assistant assistant.OperationID = "" - assistant.AdvertisedNames = []string{"foreign"} assistant.Evidence.ToolNames = []string{"foreign"} + call := validToolCallRecord() + call.Name, call.ResultEntryID, call.NormalizedArguments = "foreign", hexID(4), json.RawMessage(`{"x":1}`) + assistant.ToolCalls = []toolCallRecord{call} + result := validToolResultEntry("") + result.EntryID, result.AssistantEntry.EntryID = hexID(4), hexID(2) + result.NormalizedArguments = json.RawMessage(`{"x":1}`) + fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(4), Kind: EntryToolResult, Sequence: 3, CommittedAt: testTime}, toolResult: &result}) input := validInputEntry(testOpID) input.EntryID = hexID(3) - fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(3), OperationID: testOpID, Kind: EntryInput, Sequence: 3, CommittedAt: testTime}, input: &input}) + fixture.entries = append(fixture.entries, testEntry{env: Entry{SessionID: testSessionID, ID: hexID(3), OperationID: testOpID, Kind: EntryInput, Sequence: 4, CommittedAt: testTime}, input: &input}) fixture.ops[0].Admission.AdmittedEntry.EntryID = hexID(3) _, err := validateFixture(t, fixture.storage(t), testSessionID) if err != nil { @@ -485,17 +490,32 @@ func TestEvidenceCatalogOwnershipAndCopiedPrefix(t *testing.T) { } stored, err := encodeAssistantEntry(*assistant) testassert.NoError(t, err) - obj := wireObject(t, stored) - evidence := wireObject(t, obj["evidence"]) - evidence["tool_names"] = mustMarshal([]string{"echo"}) - obj["evidence"] = mustMarshal(evidence) - fixture.entries[1].rawOverride = mustMarshal(obj) - if _, err := validateFixture(t, fixture.storage(t), testSessionID); err == nil { - t.Fatal("stored copied advertisement mismatch accepted") - } - assistant.Evidence.ToolNames = []string{"echo"} - if _, err := encodeAssistantEntry(*assistant); err == nil { - t.Fatal("copied advertisement mismatch accepted") + for _, tc := range []struct { + name string + detail string + mutate func(map[string]json.RawMessage) + }{ + {"advertised without normalization", "advertised call", func(obj map[string]json.RawMessage) { + var calls []map[string]json.RawMessage + testassert.NoError(t, json.Unmarshal(obj["tool_calls"], &calls)) + delete(calls[0], "normalized_arguments_base64") + obj["tool_calls"] = mustMarshal(calls) + }}, + {"unadvertised with normalization", "unadvertised call", func(obj map[string]json.RawMessage) { + evidence := wireObject(t, obj["evidence"]) + evidence["tool_names"] = mustMarshal([]string{"echo"}) + obj["evidence"] = mustMarshal(evidence) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + obj := wireObject(t, stored) + tc.mutate(obj) + fixture.entries[1].rawOverride = mustMarshal(obj) + _, err := validateFixture(t, fixture.storage(t), testSessionID) + if err == nil || !strings.Contains(err.Error(), tc.detail) { + t.Fatalf("copied normalization violation = %v", err) + } + }) } } diff --git a/harness/hooks_test.go b/harness/hooks_test.go index b277f625..3d47e045 100644 --- a/harness/hooks_test.go +++ b/harness/hooks_test.go @@ -2062,9 +2062,8 @@ func TestRequestHookEmptyToolSelectionStaysHidden(t *testing.T) { committed[0].InputEvidence != nil && committed[0].InputEvidence.ToolNames != nil && len(committed[0].InputEvidence.ToolNames) == 0, "hook results = %+v, want the present-empty selection recorded as consumed", committed) assistant := compositionAssistant(t, store, sessionID) - testassert.Require(t, assistant.AdvertisedNames != nil && len(assistant.AdvertisedNames) == 0 && - assistant.Evidence != nil && assistant.Evidence.ToolNames != nil && len(assistant.Evidence.ToolNames) == 0, - "assistant = advertised %v evidence %v, want the recorded empty view carried to the boundary", assistant.AdvertisedNames, assistant.Evidence.ToolNames) + testassert.Require(t, assistant.Evidence != nil && assistant.Evidence.ToolNames != nil && len(assistant.Evidence.ToolNames) == 0, + "assistant evidence = %v, want the recorded empty view carried to the boundary", assistant.Evidence) { if _, err := validateFixture(t, store, sessionID); err != nil { t.Fatalf("graph validation: %v", err) diff --git a/harness/payload.go b/harness/payload.go index 4f5cfd15..50cf405a 100644 --- a/harness/payload.go +++ b/harness/payload.go @@ -373,38 +373,32 @@ func encodeAssistantEntry(v assistantEntry) (json.RawMessage, error) { if err != nil { return nil, err } - advertised := v.AdvertisedNames - if advertised == nil { - advertised = []string{} - } wire, err := json.Marshal(struct { - SessionID string `json:"session_id"` - EntryID string `json:"entry_id"` - OperationID string `json:"operation_id,omitempty"` - Status string `json:"status"` - Source json.RawMessage `json:"source"` - Content []json.RawMessage `json:"content"` - Refusal string `json:"refusal,omitempty"` - Extra json.RawMessage `json:"extra,omitempty"` - ToolCalls []json.RawMessage `json:"tool_calls"` - Usage *UsageCount `json:"usage,omitempty"` - RequestID string `json:"request_id"` - AdvertisedNames []string `json:"advertised_names"` - Evidence json.RawMessage `json:"evidence"` + SessionID string `json:"session_id"` + EntryID string `json:"entry_id"` + OperationID string `json:"operation_id,omitempty"` + Status string `json:"status"` + Source json.RawMessage `json:"source"` + Content []json.RawMessage `json:"content"` + Refusal string `json:"refusal,omitempty"` + Extra json.RawMessage `json:"extra,omitempty"` + ToolCalls []json.RawMessage `json:"tool_calls"` + Usage *UsageCount `json:"usage,omitempty"` + RequestID string `json:"request_id"` + Evidence json.RawMessage `json:"evidence"` }{ - SessionID: v.SessionID, - EntryID: v.EntryID, - OperationID: v.OperationID, - Status: string(v.Status), - Source: source, - Content: content, - Refusal: v.Refusal, - Extra: extra, - ToolCalls: calls, - Usage: v.Usage, - RequestID: v.RequestID, - AdvertisedNames: advertised, - Evidence: evidence, + SessionID: v.SessionID, + EntryID: v.EntryID, + OperationID: v.OperationID, + Status: string(v.Status), + Source: source, + Content: content, + Refusal: v.Refusal, + Extra: extra, + ToolCalls: calls, + Usage: v.Usage, + RequestID: v.RequestID, + Evidence: evidence, }) if err != nil { return nil, err @@ -423,7 +417,7 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { if err != nil { return assistantEntry{}, err } - if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "status", "source", "content", "refusal", "extra", "tool_calls", "usage", "request_id", "advertised_names", "evidence"); err != nil { + if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "status", "source", "content", "refusal", "extra", "tool_calls", "usage", "request_id", "evidence"); err != nil { return assistantEntry{}, err } sessionID, err := stringMember(obj, "session_id", true) @@ -482,13 +476,6 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { if err != nil { return assistantEntry{}, err } - advertisedNames, err := optionalStringMemberList(obj, "advertised_names") - if err != nil { - return assistantEntry{}, err - } - if advertisedNames == nil { - return assistantEntry{}, errors.New(`required member "advertised_names" is missing`) - } evidence, err := decodeRequestEvidence(obj["evidence"]) if err != nil { return assistantEntry{}, fmt.Errorf("member %q: %w", "evidence", err) @@ -503,17 +490,16 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { return assistantEntry{}, fmt.Errorf("payload operation id %q does not agree with the envelope", operationID) } v := assistantEntry{ - SessionID: sessionID, - EntryID: entryID, - OperationID: operationID, - Status: model.OutputStatus(status), - Source: source, - Refusal: refusal, - Extra: extra, - Usage: usage, - RequestID: requestID, - AdvertisedNames: advertisedNames, - Evidence: &evidence, + SessionID: sessionID, + EntryID: entryID, + OperationID: operationID, + Status: model.OutputStatus(status), + Source: source, + Refusal: refusal, + Extra: extra, + Usage: usage, + RequestID: requestID, + Evidence: &evidence, } for i, raw := range contentRaw { part, err := decodeContentPart(raw) @@ -592,14 +578,14 @@ func validateAssistantEntry(v assistantEntry) error { if err := validateOperationIdentity(v.RequestID, "assistant request id"); err != nil { return err } - seenNames := make(map[string]bool, len(v.AdvertisedNames)) - for _, name := range v.AdvertisedNames { - if name == "" { - return errors.New("assistant advertised_names carries an empty name") - } - if seenNames[name] { - return fmt.Errorf("assistant advertised_names has duplicate name %q", name) - } + if v.Evidence == nil { + return errors.New("assistant entry requires its effective request evidence") + } + if err := validateRequestEvidence(*v.Evidence); err != nil { + return err + } + seenNames := make(map[string]bool, len(v.Evidence.ToolNames)) + for _, name := range v.Evidence.ToolNames { seenNames[name] = true } // One closed normalization rule over the producing request's advertised @@ -617,20 +603,6 @@ func validateAssistantEntry(v assistantEntry) error { return fmt.Errorf("tool_calls[%d]: unadvertised call %q must carry neither normalized_arguments nor normalization_failure", i, call.ID) } } - if v.Evidence == nil { - return errors.New("assistant entry requires its effective request evidence") - } - if err := validateRequestEvidence(*v.Evidence); err != nil { - return err - } - if len(v.Evidence.ToolNames) != len(v.AdvertisedNames) { - return errors.New("assistant evidence does not match advertised names") - } - for i, name := range v.AdvertisedNames { - if v.Evidence.ToolNames[i] != name { - return errors.New("assistant evidence does not match advertised names") - } - } if !assistantPayloadEligible(v) { return errors.New("assistant entry carries no eligible model-visible payload (finalized content part, refusal, finalized extra, or, for completed output only, tool calls)") } diff --git a/harness/public_test.go b/harness/public_test.go index e9130c4c..93601f21 100644 --- a/harness/public_test.go +++ b/harness/public_test.go @@ -3316,7 +3316,7 @@ func rawAssistantEntryPayload(sessionID, entryID, operationID, boundaryID string } return fmt.Sprintf( `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, + `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, sessionID, entryID, operationID, strings.Join(parts, ","), "request-a-"+entryID, boundaryID) } @@ -3804,7 +3804,7 @@ func rawCopiedAssistantPayload(sessionID, entryID, boundaryID string, calls [][2 } return fmt.Sprintf( `{"session_id":%q,"entry_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, + `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s],"request_id":%q,"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, sessionID, entryID, strings.Join(parts, ","), "request-a-"+entryID, boundaryID) } @@ -4917,7 +4917,6 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { mutate: func(t *testing.T, snap *sessionSnapshot) { entry := snapEntryOf(snap, harness.EntryAssistant) entry.Payload = editPayloadObject(t, entry.Payload, func(obj map[string]json.RawMessage) { - obj["advertised_names"] = json.RawMessage(`["foreign"]`) obj["evidence"] = editPayloadObject(t, obj["evidence"], func(ev map[string]json.RawMessage) { ev["tool_names"] = json.RawMessage(`["foreign"]`) }) }) }, @@ -6628,7 +6627,7 @@ func rawSettledSuccessOperation(t *testing.T, store harness.Storage, sessionID, sessionID, inputID, operationID, inputText)) insertRawEntry(t, store, sessionID, assistantID, operationID, harness.EntryAssistant, fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, + `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"evidence":{"boundary_entry_id":%q,"prompt":"system","tool_names":[]}}`, sessionID, assistantID, operationID, assistantText, "request-a-"+assistantID, inputID)) insertRawEntry(t, store, sessionID, settlementID, operationID, harness.EntryOperationSettlement, fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"success"}`, sessionID, settlementID, operationID)) diff --git a/harness/schema.go b/harness/schema.go index 7cbf9b95..c4c1559c 100644 --- a/harness/schema.go +++ b/harness/schema.go @@ -423,22 +423,21 @@ type toolCallRecord struct { // assistantEntry is one finalized model output: status, complete source // identity, eligible model-visible payload, reported usage when present, and -// the producing request identity with its final effective request evidence -// and advertised names. The evidence is not conversation text. +// the producing request identity with its final effective request evidence. +// The evidence carries the advertisement and is not conversation text. type assistantEntry struct { - SessionID string `json:"session_id"` - EntryID string `json:"entry_id"` - OperationID string `json:"operation_id,omitempty"` - Status model.OutputStatus `json:"status"` - Source model.ModelRef `json:"source"` - Content []model.ContentPart `json:"content"` - Refusal string `json:"refusal,omitempty"` - Extra model.Extra `json:"extra,omitempty"` - ToolCalls []toolCallRecord `json:"tool_calls"` - Usage *UsageCount `json:"usage,omitempty"` - RequestID string `json:"request_id"` - AdvertisedNames []string `json:"advertised_names"` - Evidence *RequestEvidence `json:"evidence"` + SessionID string `json:"session_id"` + EntryID string `json:"entry_id"` + OperationID string `json:"operation_id,omitempty"` + Status model.OutputStatus `json:"status"` + Source model.ModelRef `json:"source"` + Content []model.ContentPart `json:"content"` + Refusal string `json:"refusal,omitempty"` + Extra model.Extra `json:"extra,omitempty"` + ToolCalls []toolCallRecord `json:"tool_calls"` + Usage *UsageCount `json:"usage,omitempty"` + RequestID string `json:"request_id"` + Evidence *RequestEvidence `json:"evidence"` } // toolResultEntry is one settled tool result answering a published call by diff --git a/harness/snapshot.go b/harness/snapshot.go index 455beb2e..69815c52 100644 --- a/harness/snapshot.go +++ b/harness/snapshot.go @@ -576,9 +576,6 @@ func ownAssistantPayload(v assistantEntry) *assistantEntry { usage := *v.Usage out.Usage = &usage } - if v.AdvertisedNames != nil { - out.AdvertisedNames = append([]string(nil), v.AdvertisedNames...) - } out.Evidence = copyRequestEvidencePointer(v.Evidence) return &out } diff --git a/harness/snapshot_test.go b/harness/snapshot_test.go index f90b8504..d85f3ea8 100644 --- a/harness/snapshot_test.go +++ b/harness/snapshot_test.go @@ -76,7 +76,6 @@ func factFixtureGraph() *testGraph { assistant1 := validAssistantEntry(testOpID) assistant1.EntryID = hexID(2) - assistant1.AdvertisedNames = []string{"echo"} assistant1.Evidence.ToolNames = []string{"echo"} assistant1.Extra = model.Extra{"reasoning": json.RawMessage(`{"deep":[1]}`)} call := validToolCallRecord() diff --git a/harness/strategy_review_test.go b/harness/strategy_review_test.go index 98037dc4..e869bb02 100644 --- a/harness/strategy_review_test.go +++ b/harness/strategy_review_test.go @@ -2,7 +2,9 @@ package harness import ( "context" + "encoding/json" "errors" + "reflect" "strings" "testing" @@ -591,7 +593,8 @@ func TestReviewRetainResolution(t *testing.T) { frozen, frozenCheckpoint, frozenBoundary, ferr := h.projectCanonical(c, testOpID) testassert.NoError(t, ferr) pristine := frozen[0].TextContent() - planInput := PlanInput{Trigger: triggerManual, Messages: frozen, CheckpointEntryID: frozenCheckpoint, BoundaryEntryID: frozenBoundary} + contributions := RequestEvidence{CheckpointEntryID: frozenCheckpoint, BoundaryEntryID: frozenBoundary, Prompt: "accepted prompt", ToolNames: []string{"echo"}, Messages: []model.Message{{Role: model.RoleUser, Content: []model.ContentPart{{Kind: model.PartText, Text: "addition", Extra: model.Extra{"raw": json.RawMessage(` { "n" : 1 } `)}}}}}} + planInput := PlanInput{Trigger: triggerManual, Messages: frozen, CheckpointEntryID: frozenCheckpoint, BoundaryEntryID: frozenBoundary, Contributions: &contributions} checkpoint, facts, closeMaintenance, err := h.runContextMaintenance(context.Background(), c, testOpID, exec, capture, planInput, &RunningRequest{RequestID: "request-conversation", BoundaryEntryID: fixtureInputEntryID(c)}) defer closeMaintenance() @@ -607,6 +610,7 @@ func TestReviewRetainResolution(t *testing.T) { "checkpoint message = %+v, want the frozen source copy resolved unchanged (pristine %q)", comp.Messages, pristine) testassert.Require(t, planInput.Messages[0].TextContent() == pristine, "the frozen input copy was rewritten to %q", planInput.Messages[0].TextContent()) + testassert.Require(t, contributions.Prompt == "accepted prompt" && reflect.DeepEqual(contributions.ToolNames, []string{"echo"}) && contributions.Messages[0].TextContent() == "addition" && string(contributions.Messages[0].Content[0].Extra["raw"]) == ` { "n" : 1 } ` && reflect.DeepEqual(comp.Evidence, &contributions), "strategy changed accepted contribution: input=%+v evidence=%+v", contributions, comp.Evidence) }) } @@ -617,6 +621,12 @@ type mutatingStrategy struct{ passes int } func (s *mutatingStrategy) Plan(in PlanInput) (PlanOutput, error) { s.passes++ + if in.Contributions != nil { + in.Contributions.Prompt = "mutated" + in.Contributions.ToolNames[0] = "mutated" + in.Contributions.Messages[0].Content[0].Text = "mutated" + in.Contributions.Messages[0].Content[0].Extra["raw"][0] = 'x' + } for i := range in.Messages { for j := range in.Messages[i].Content { in.Messages[i].Content[j].Text = "mutated by the callback" diff --git a/runtime/compact_lifecycle_test.go b/runtime/compact_lifecycle_test.go index 985d2f4c..affd622e 100644 --- a/runtime/compact_lifecycle_test.go +++ b/runtime/compact_lifecycle_test.go @@ -341,7 +341,7 @@ func compactRawInputEntry(sessionID, entryID, operationID, origin, text string) func compactRawAssistantEntry(sessionID, entryID, operationID, boundaryID, text string) string { return fmt.Sprintf( `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ - `"source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"advertised_names":[],"evidence":{"boundary_entry_id":%q,"prompt":"compact-sys","tool_names":[]}}`, + `"source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}],"tool_calls":[],"request_id":%q,"evidence":{"boundary_entry_id":%q,"prompt":"compact-sys","tool_names":[]}}`, sessionID, entryID, operationID, text, "request-a-"+entryID, boundaryID) } diff --git a/runtime/protocol_paging_test.go b/runtime/protocol_paging_test.go index 1665aeb0..445aa252 100644 --- a/runtime/protocol_paging_test.go +++ b/runtime/protocol_paging_test.go @@ -414,7 +414,6 @@ func usageSeedMessageOperation(t *testing.T, store harness.Storage, sessionID, o ToolCalls []harness.ToolCallRecord `json:"tool_calls"` Usage *harness.UsageCount `json:"usage,omitempty"` RequestID string `json:"request_id"` - Advertised []string `json:"advertised_names"` Evidence json.RawMessage `json:"evidence"` }{ SessionID: sessionID, EntryID: assistantID, OperationID: operationID, @@ -422,7 +421,7 @@ func usageSeedMessageOperation(t *testing.T, store harness.Storage, sessionID, o Content: json.RawMessage(`[{"kind":"text","text":"answer ` + operationID + `"}]`), ToolCalls: []harness.ToolCallRecord{}, Usage: assistantUsage, - RequestID: "request-a-" + assistantID, Advertised: []string{}, Evidence: json.RawMessage(`{"boundary_entry_id":"` + inputID + `","prompt":"compact-sys","tool_names":[]}`), + RequestID: "request-a-" + assistantID, Evidence: json.RawMessage(`{"boundary_entry_id":"` + inputID + `","prompt":"compact-sys","tool_names":[]}`), }) testassert.NoError(t, err) settlementExtra := ""