mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
9657051e43
Item responses carry implementation_notes and decision_log in TWO places: 1. Top-level arrays on the item (item.ImplementationNotes, item.DecisionLog) — populated by hydrateItemComputedMetadata. 2. Inside the stringified `fields` blob — written there by AppendImplementationNote / AppendDecisionLogEntry at write time. This duplicate forces agents to dedup or pick a source; the bug report's recommendation was to keep the top-level arrays as the canonical shape and drop the embed. Path-consistent with BUG-991 path A (also MCP-only normalization): extend the boundary normalizer in packageJSONResult so that AFTER fields is parsed (BUG-991), implementation_notes and decision_log keys are dropped from the parsed fields object. The top-level arrays continue to surface unchanged. Server / web / CLI keep their existing behavior — fields still carries the embed at rest, hydration still extracts to top-level. The boundary fix is the cheap clean-up; a write-side migration (stop persisting into the fields blob, plus a one-shot data migration to clean existing items) is the architecturally proper fix and remains tracked in BUG-992's notes. Tests: - internal/mcp/bug992_test.go: stripDuplicatedFieldsKeys helper (strips both keys, no-op when absent, defensive on non-object inputs) + end-to-end packageJSONResult cases for single-item and array-style responses. Parent: BUG-992.
132 lines
4.3 KiB
Go
132 lines
4.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// TestStripDuplicatedFieldsKeys covers the dedup helper behavior in
|
|
// isolation. Both implementation_notes and decision_log get dropped;
|
|
// other keys pass through untouched.
|
|
func TestStripDuplicatedFieldsKeys(t *testing.T) {
|
|
t.Run("strips both", func(t *testing.T) {
|
|
in := map[string]any{
|
|
"status": "open",
|
|
"priority": "high",
|
|
"implementation_notes": []any{map[string]any{"id": "n1"}},
|
|
"decision_log": []any{map[string]any{"id": "d1"}},
|
|
}
|
|
out := stripDuplicatedFieldsKeys(in).(map[string]any)
|
|
if _, has := out["implementation_notes"]; has {
|
|
t.Errorf("implementation_notes not stripped: %v", out)
|
|
}
|
|
if _, has := out["decision_log"]; has {
|
|
t.Errorf("decision_log not stripped: %v", out)
|
|
}
|
|
if out["status"] != "open" || out["priority"] != "high" {
|
|
t.Errorf("non-duplicate keys lost: %v", out)
|
|
}
|
|
})
|
|
|
|
t.Run("no-op when keys absent", func(t *testing.T) {
|
|
in := map[string]any{"status": "open"}
|
|
out := stripDuplicatedFieldsKeys(in).(map[string]any)
|
|
if out["status"] != "open" {
|
|
t.Errorf("status lost: %v", out)
|
|
}
|
|
if len(out) != 1 {
|
|
t.Errorf("unexpected keys present: %v", out)
|
|
}
|
|
})
|
|
|
|
t.Run("non-object input passes through", func(t *testing.T) {
|
|
// Defensive: if a hand-written fields value is somehow a
|
|
// primitive or array, stripDuplicatedFieldsKeys must not
|
|
// panic.
|
|
cases := []any{
|
|
nil,
|
|
"a string",
|
|
float64(42),
|
|
[]any{1, 2, 3},
|
|
}
|
|
for _, in := range cases {
|
|
out := stripDuplicatedFieldsKeys(in)
|
|
// Just confirm no crash and the value is returned (we
|
|
// don't compare DeepEqual because nil passes through as nil
|
|
// and we want to avoid the type-assertion noise).
|
|
_ = out
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestPackageJSONResult_StripsDuplicatedNotesAndLog is the end-to-end
|
|
// regression: a CLI body that carries top-level
|
|
// implementation_notes / decision_log arrays AND embeds them inside
|
|
// the fields blob — exactly the duplication BUG-987 bug 10 reported —
|
|
// produces a normalized structuredContent where the embeds are gone
|
|
// and the top-level arrays remain.
|
|
func TestPackageJSONResult_StripsDuplicatedNotesAndLog(t *testing.T) {
|
|
body := `{
|
|
"ref": "TASK-7",
|
|
"implementation_notes": [{"id":"n1","summary":"Outer note"}],
|
|
"decision_log": [{"id":"d1","decision":"Outer decision"}],
|
|
"fields": "{\"status\":\"open\",\"implementation_notes\":[{\"id\":\"n1\"}],\"decision_log\":[{\"id\":\"d1\"}]}"
|
|
}`
|
|
res := packageJSONResult(body)
|
|
sc := res.StructuredContent.(map[string]any)
|
|
|
|
// Top-level arrays preserved verbatim.
|
|
notes, ok := sc["implementation_notes"].([]any)
|
|
if !ok || len(notes) != 1 {
|
|
t.Errorf("top-level implementation_notes lost or wrong shape: %v", sc["implementation_notes"])
|
|
}
|
|
log, ok := sc["decision_log"].([]any)
|
|
if !ok || len(log) != 1 {
|
|
t.Errorf("top-level decision_log lost or wrong shape: %v", sc["decision_log"])
|
|
}
|
|
|
|
// fields parsed (BUG-991) AND the duplicated entries removed.
|
|
fields, ok := sc["fields"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("fields = %T, want map[string]any", sc["fields"])
|
|
}
|
|
if fields["status"] != "open" {
|
|
t.Errorf("fields.status lost: %v", fields)
|
|
}
|
|
if _, has := fields["implementation_notes"]; has {
|
|
t.Errorf("fields.implementation_notes not stripped: %v", fields)
|
|
}
|
|
if _, has := fields["decision_log"]; has {
|
|
t.Errorf("fields.decision_log not stripped: %v", fields)
|
|
}
|
|
}
|
|
|
|
// TestPackageJSONResult_StripsAcrossArrayItems confirms the dedup
|
|
// happens for every entry in a list-style response, not just the
|
|
// top-level item.
|
|
func TestPackageJSONResult_StripsAcrossArrayItems(t *testing.T) {
|
|
body := `[
|
|
{"ref":"TASK-1","fields":"{\"status\":\"open\",\"implementation_notes\":[{\"id\":\"n1\"}]}"},
|
|
{"ref":"TASK-2","fields":"{\"status\":\"done\",\"decision_log\":[{\"id\":\"d1\"}]}"}
|
|
]`
|
|
res := packageJSONResult(body)
|
|
wrapped := res.StructuredContent.(map[string]any)
|
|
items := wrapped["items"].([]any)
|
|
if len(items) != 2 {
|
|
t.Fatalf("expected 2 items, got %d", len(items))
|
|
}
|
|
for i, it := range items {
|
|
m := it.(map[string]any)
|
|
fields, ok := m["fields"].(map[string]any)
|
|
if !ok {
|
|
t.Errorf("items[%d].fields = %T, want map", i, m["fields"])
|
|
continue
|
|
}
|
|
if _, has := fields["implementation_notes"]; has {
|
|
t.Errorf("items[%d].fields.implementation_notes not stripped", i)
|
|
}
|
|
if _, has := fields["decision_log"]; has {
|
|
t.Errorf("items[%d].fields.decision_log not stripped", i)
|
|
}
|
|
}
|
|
}
|