Files
pad/internal/mcp/bug991_test.go
T
xarmian 708897dd0c fix(mcp): parse fields/tags at MCP boundary so agents see native shapes (BUG-991 path A) (#364)
Item responses carry `fields` and `tags` as JSON-stringified strings
because the underlying SQLite columns store them that way. For agents
going through MCP this means a double-encode every read — they have
to JSON.parse the field's string value before doing anything useful.

Path A (this PR): normalize at the MCP boundary. Recursively walk
parsed JSON in packageJSONResult, find string-typed `fields` and
`tags` properties, parse the embedded JSON, substitute the native
shape. Server / web / CLI keep their existing stringified contract;
agents see clean JSON.

The walk handles every common item shape:
- Single-item responses (top-level item)
- Item arrays (item list, dashboard.active_items, comment lists)
- Nested items (dashboard.recent_activity[].item, parent_*)

Conservative parse: only strings starting with `{` or `[` and
successfully parseable as JSON get substituted. Hand-written values
that happen to share a key name (e.g. a `fields` description text)
pass through untouched. Malformed JSON also passes through as the
original string rather than dropping the value.

Text fallback (content[0].text on the MCP result) preserves the
original CLI body verbatim. Older clients that read the text content
keep seeing the same shape — the structured wire is the agent
upgrade path; text is the back-compat path. Same pattern as BUG-985's
{items: [...]} array wrap.

Path B (full migration of models.Item.Fields from string to
map[string]any across server/store/web/CLI) is tracked in BUG-991's
notes — bigger surgery, deferred.

Tests:
- internal/mcp/bug991_test.go: single-item, item-array, nested-item,
  primitives-pass-through, malformed-stays-string, plus end-to-end
  packageJSONResult cases proving structured + text branches both
  work.
- Existing dispatch_http_advanced_test.go and dispatch_http_project_test.go
  updated: tests that previously did `json.Unmarshal([]byte(fieldsStr))`
  now use a small itemFieldsAsMap helper that reads the parsed map
  directly. Cleaner, and a clear error message if normalization
  regresses.

Parent: BUG-991.
2026-05-01 21:35:29 -04:00

239 lines
7.8 KiB
Go

package mcp
import (
"encoding/json"
"reflect"
"testing"
)
// TestNormalizeStringEncodedJSONFields_SingleItem covers the most
// common shape — a single-item response with stringified `fields`
// and `tags`. After the BUG-991 path-A normalization at the MCP
// boundary, both come back as native types.
func TestNormalizeStringEncodedJSONFields_SingleItem(t *testing.T) {
in := map[string]any{
"ref": "TASK-5",
"title": "Test",
"fields": `{"status":"open","priority":"high"}`,
"tags": `["frontend","auth"]`,
}
got := normalizeStringEncodedJSONFields(in)
gotMap, ok := got.(map[string]any)
if !ok {
t.Fatalf("got %T, want map", got)
}
fields, ok := gotMap["fields"].(map[string]any)
if !ok {
t.Fatalf("fields = %T, want map[string]any", gotMap["fields"])
}
if fields["status"] != "open" || fields["priority"] != "high" {
t.Errorf("fields content lost: %v", fields)
}
tags, ok := gotMap["tags"].([]any)
if !ok {
t.Fatalf("tags = %T, want []any", gotMap["tags"])
}
if len(tags) != 2 || tags[0] != "frontend" || tags[1] != "auth" {
t.Errorf("tags content lost: %v", tags)
}
}
// TestNormalizeStringEncodedJSONFields_ItemArray covers list-style
// responses where every entry has stringified fields/tags. Each
// item gets normalized independently.
func TestNormalizeStringEncodedJSONFields_ItemArray(t *testing.T) {
in := []any{
map[string]any{
"ref": "TASK-1",
"fields": `{"status":"open"}`,
"tags": `[]`,
},
map[string]any{
"ref": "TASK-2",
"fields": `{"status":"done","priority":"low"}`,
"tags": `["bug"]`,
},
}
got := normalizeStringEncodedJSONFields(in).([]any)
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
first := got[0].(map[string]any)
second := got[1].(map[string]any)
if _, ok := first["fields"].(map[string]any); !ok {
t.Errorf("first.fields = %T, want map", first["fields"])
}
if _, ok := first["tags"].([]any); !ok {
t.Errorf("first.tags = %T, want []any", first["tags"])
}
if _, ok := second["fields"].(map[string]any); !ok {
t.Errorf("second.fields = %T, want map", second["fields"])
}
}
// TestNormalizeStringEncodedJSONFields_NestedItem covers shapes with
// items embedded inside a wrapping envelope (e.g. dashboard's
// active_items, recent_activity, etc.). Each nested item still gets
// its fields/tags normalized.
func TestNormalizeStringEncodedJSONFields_NestedItem(t *testing.T) {
in := map[string]any{
"summary": map[string]any{"total": 5},
"active_items": []any{
map[string]any{
"ref": "TASK-1",
"fields": `{"status":"in-progress"}`,
},
},
"recent_activity": []any{
map[string]any{
"action": "updated",
"item": map[string]any{
"ref": "TASK-2",
"fields": `{"status":"done"}`,
"tags": `["release"]`,
},
},
},
}
got := normalizeStringEncodedJSONFields(in).(map[string]any)
active := got["active_items"].([]any)
first := active[0].(map[string]any)
if _, ok := first["fields"].(map[string]any); !ok {
t.Errorf("active_items[0].fields = %T, want map", first["fields"])
}
activity := got["recent_activity"].([]any)
embeddedItem := activity[0].(map[string]any)["item"].(map[string]any)
if _, ok := embeddedItem["fields"].(map[string]any); !ok {
t.Errorf("recent_activity[0].item.fields = %T, want map", embeddedItem["fields"])
}
if _, ok := embeddedItem["tags"].([]any); !ok {
t.Errorf("recent_activity[0].item.tags = %T, want []any", embeddedItem["tags"])
}
}
// TestNormalizeStringEncodedJSONFields_LeavesUnrelatedStringsAlone is
// the safety net: free-form `tags` / `fields` keys outside item
// contexts (e.g. `metadata` blobs) shouldn't be touched if their
// strings don't look like JSON. Conservative: only objects with
// leading `{` or `[` are candidates for substitution.
func TestNormalizeStringEncodedJSONFields_LeavesUnrelatedStringsAlone(t *testing.T) {
in := map[string]any{
"ref": "TASK-1",
"fields": `not actually json`,
"tags": ``,
"deeply": map[string]any{
"nested": map[string]any{
"unrelated": "value",
},
},
}
got := normalizeStringEncodedJSONFields(in).(map[string]any)
if got["fields"] != "not actually json" {
t.Errorf("non-JSON fields modified: %v", got["fields"])
}
if got["tags"] != "" {
t.Errorf("empty tags modified: %v", got["tags"])
}
// Sanity: nested unrelated values stay where they are.
deeply, _ := got["deeply"].(map[string]any)
nested, _ := deeply["nested"].(map[string]any)
if nested["unrelated"] != "value" {
t.Errorf("nested traversal mutated unrelated leaves: %v", nested)
}
}
// TestNormalizeStringEncodedJSONFields_PrimitivesPassThrough confirms
// the walk doesn't bomb on primitive values at any depth. Any non-
// (map|slice) input is returned unchanged.
func TestNormalizeStringEncodedJSONFields_PrimitivesPassThrough(t *testing.T) {
cases := []any{
nil,
"hello",
float64(42),
true,
[]any{},
map[string]any{},
}
for _, in := range cases {
got := normalizeStringEncodedJSONFields(in)
if !reflect.DeepEqual(got, in) {
t.Errorf("got %v, want %v (input %v)", got, in, in)
}
}
}
// TestPackageJSONResult_NormalizesItemFields end-to-end test: a CLI
// stdout body containing a single item with stringified fields/tags
// produces structuredContent where both are native shapes. The text
// fallback is unchanged (raw CLI body) so older clients that read
// content[0].text keep seeing the same shape.
func TestPackageJSONResult_NormalizesItemFields(t *testing.T) {
body := `{"ref":"TASK-5","title":"x","fields":"{\"status\":\"open\",\"priority\":\"high\"}","tags":"[\"frontend\"]"}`
res := packageJSONResult(body)
if res.IsError {
t.Fatalf("unexpected IsError")
}
sc := res.StructuredContent.(map[string]any)
fields, ok := sc["fields"].(map[string]any)
if !ok {
t.Fatalf("structuredContent.fields = %T, want map[string]any", sc["fields"])
}
if fields["status"] != "open" || fields["priority"] != "high" {
t.Errorf("fields content wrong: %v", fields)
}
tags, ok := sc["tags"].([]any)
if !ok || len(tags) != 1 || tags[0] != "frontend" {
t.Errorf("structuredContent.tags wrong: %v", sc["tags"])
}
// Text fallback preserves the original CLI body verbatim. Older
// clients that don't parse structuredContent should NOT see a
// breaking shape change.
if got := textOf(res); got != body {
t.Errorf("text fallback diverged:\n got %q\nwant %q", got, body)
}
}
// TestPackageJSONResult_ArrayItemsNormalized confirms list-style
// responses are normalized too, in combination with the BUG-985
// {items: [...]} array wrap.
func TestPackageJSONResult_ArrayItemsNormalized(t *testing.T) {
body := `[{"ref":"TASK-1","fields":"{\"status\":\"open\"}"},{"ref":"TASK-2","fields":"{\"status\":\"done\"}"}]`
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)
if _, ok := m["fields"].(map[string]any); !ok {
t.Errorf("items[%d].fields = %T, want map", i, m["fields"])
}
}
}
// TestPackageJSONResult_MalformedFieldsStaysString covers the safety
// net at the end-to-end level: if `fields` carries an invalid JSON
// string, leave it as a string rather than dropping it. Agents that
// see a string here can still surface the underlying value.
func TestPackageJSONResult_MalformedFieldsStaysString(t *testing.T) {
body := `{"ref":"TASK-X","fields":"{not actually json"}`
res := packageJSONResult(body)
sc := res.StructuredContent.(map[string]any)
fields, ok := sc["fields"].(string)
if !ok {
t.Fatalf("malformed fields should stay string; got %T", sc["fields"])
}
if fields != "{not actually json" {
t.Errorf("malformed fields content lost: %q", fields)
}
}
// _ silences a potential unused-import warning on encoding/json if a
// later refactor stops using it directly. Keeps the file resilient
// without affecting the test inventory.
var _ = json.Marshal