[object Object]

← back to Cli Printing Press

fix(cli): preserve body/content payload on single-object compact path (#1509)

c7e1b82b85ef47df1a011100fcb31a42a095b7ac · 2026-05-16 02:52:06 -0700 · Trevin Chow

* fix(cli): preserve body/content payload on single-object compact path

The single helpers.go map compactVerboseFields was consulted by both
compactListFields and compactObjectFields. On a list, "body"/"content"/
"html"/"markdown" are verbose noise and stripping them is right. On a
single-object `get --agent` response those fields are the primary
payload, and stripping them silently returns a useless envelope with
no error and no stderr signal.

Split the map into compactVerboseListFields (unchanged contents) and
compactVerboseObjectFields (description/comments/attachments only),
so compactObjectFields preserves payload fields while list compaction
behaves exactly as before. Agents that still want to omit body/content
on `get` can pass `--select`.

Closes #1481

* test(cli): use balanced-brace scan to bound compactVerboseObjectFields literal

Greptile flagged that strings.Index(body[objStart:], "}") returns the
first `}` after the map declaration, which works today (no intermediate
braces) but would silently truncate objBody if a future edit added a
field whose value contained `}` or an inline comment with `}`, causing
the NotContains assertions to give false-positive passes.

Replace with a small matchClosingBrace helper that counts brace depth
from the first `{` and returns the index of the matching close. Same
test semantics; resilient to future map shape changes.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit c7e1b82b85ef47df1a011100fcb31a42a095b7ac
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 16 02:52:06 2026 -0700

    fix(cli): preserve body/content payload on single-object compact path (#1509)
    
    * fix(cli): preserve body/content payload on single-object compact path
    
    The single helpers.go map compactVerboseFields was consulted by both
    compactListFields and compactObjectFields. On a list, "body"/"content"/
    "html"/"markdown" are verbose noise and stripping them is right. On a
    single-object `get --agent` response those fields are the primary
    payload, and stripping them silently returns a useless envelope with
    no error and no stderr signal.
    
    Split the map into compactVerboseListFields (unchanged contents) and
    compactVerboseObjectFields (description/comments/attachments only),
    so compactObjectFields preserves payload fields while list compaction
    behaves exactly as before. Agents that still want to omit body/content
    on `get` can pass `--select`.
    
    Closes #1481
    
    * test(cli): use balanced-brace scan to bound compactVerboseObjectFields literal
    
    Greptile flagged that strings.Index(body[objStart:], "}") returns the
    first `}` after the map declaration, which works today (no intermediate
    braces) but would silently truncate objBody if a future edit added a
    field whose value contained `}` or an inline comment with `}`, causing
    the NotContains assertions to give false-positive passes.
    
    Replace with a small matchClosingBrace helper that counts brace depth
    from the first `{` and returns the index of the matching close. Same
    test semantics; resilient to future map shape changes.
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/generator/generator_test.go               | 59 +++++++++++++++++++++-
 internal/generator/templates/helpers.go.tmpl       | 30 ++++++++---
 .../embedded-paged-api/internal/cli/helpers.go     | 30 ++++++++---
 .../internal/cli/helpers.go                        | 30 ++++++++---
 .../printing-press-golden/internal/cli/helpers.go  | 30 ++++++++---
 5 files changed, 146 insertions(+), 33 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index da908f17..5bd95d38 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -4054,12 +4054,69 @@ func TestCompactListFieldsPreservesUnknownShapes(t *testing.T) {
 		"compactListFields must count per-key occurrence so frequent novel-command keys survive")
 	assert.Contains(t, body, "isCompactScalar",
 		"compactListFields must filter the data-driven extension by scalar type so nested objects/arrays don't bloat --compact output")
-	assert.Contains(t, body, "compactVerboseFields",
+	assert.Contains(t, body, "compactVerboseListFields",
 		"compactListFields must exclude description/body/content from the data-driven extension regardless of frequency")
 	assert.Contains(t, body, "threshold",
 		"compactListFields must compute a frequency threshold for the data-driven extension")
 }
 
+// matchClosingBrace walks s from start, finds the first `{`, then returns
+// the index of the matching `}` by counting depth. Returns -1 if either no
+// opening brace exists at/after start or the input is unbalanced.
+func matchClosingBrace(s string, start int) int {
+	depth := 0
+	seenOpen := false
+	for i := start; i < len(s); i++ {
+		switch s[i] {
+		case '{':
+			depth++
+			seenOpen = true
+		case '}':
+			depth--
+			if seenOpen && depth == 0 {
+				return i
+			}
+		}
+	}
+	return -1
+}
+
+// TestCompactObjectFieldsPreservesPayloadFields pins the contract that
+// single-object `get` responses retain their primary payload fields under
+// `--agent`/`--compact`. The list-path blocklist correctly strips body/
+// content/html/markdown from list items (verbose noise), but applying the
+// same blocklist to single-object responses silently drops the field the
+// caller asked for. Pinned at the template level so the two blocklists stay
+// separate as the helper renders into every printed CLI.
+func TestCompactObjectFieldsPreservesPayloadFields(t *testing.T) {
+	t.Parallel()
+
+	path := filepath.Join("templates", "helpers.go.tmpl")
+	data, err := os.ReadFile(path)
+	require.NoError(t, err, "template must exist: %s", path)
+	body := string(data)
+
+	require.Contains(t, body, "compactVerboseObjectFields",
+		"compactObjectFields must read from a dedicated object-path blocklist, not the list-path one")
+	require.Contains(t, body, "if !compactVerboseObjectFields[k] {",
+		"compactObjectFields must consult the object-path blocklist when deciding which keys to keep")
+
+	objStart := strings.Index(body, "compactVerboseObjectFields = map[string]bool{")
+	require.GreaterOrEqual(t, objStart, 0, "compactVerboseObjectFields map literal must exist")
+	objEnd := matchClosingBrace(body, objStart)
+	require.GreaterOrEqual(t, objEnd, 0, "compactVerboseObjectFields map literal must close")
+	objBody := body[objStart : objEnd+1]
+
+	for _, payload := range []string{`"body"`, `"content"`, `"html"`, `"markdown"`} {
+		assert.NotContains(t, objBody, payload,
+			"compactVerboseObjectFields must not list %s — those fields are the primary payload on single-object get responses", payload)
+	}
+	for _, metadata := range []string{`"description"`, `"comments"`, `"attachments"`} {
+		assert.Contains(t, objBody, metadata,
+			"compactVerboseObjectFields must still strip %s — that field is metadata on single-object responses, not payload", metadata)
+	}
+}
+
 // The cursor param's "0" must survive paginatedGet's zero-value strip:
 // offset-paginated APIs require offset=0 on the first page request.
 func TestPaginatedGetExemptsCursorParamFromZeroStripping(t *testing.T) {
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index abdca004..7ef3fd34 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -1122,14 +1122,25 @@ func extractResponseData(data json.RawMessage) json.RawMessage {
 	}
 }
 
-// compactVerboseFields are the prose-shaped fields stripped by both the
-// list and single-object compact paths. Centralised so the contract stays
-// in lockstep across both helpers.
-var compactVerboseFields = map[string]bool{
+// compactVerboseListFields are prose-shaped fields stripped from list-item
+// projections. On lists, "body"/"content"/"html"/"markdown" are verbose
+// noise and the row's identity is carried by id/name/title/etc.
+var compactVerboseListFields = map[string]bool{
 	"description": true, "body": true, "content": true,
 	"comments": true, "attachments": true, "html": true, "markdown": true,
 }
 
+// compactVerboseObjectFields are metadata fields stripped from single-object
+// responses. "body"/"content"/"html"/"markdown" are intentionally absent:
+// for a `get` command those fields are the primary payload, and stripping
+// them under `--agent`/`--compact` silently emits a useless envelope.
+// Use `--select` to drop them explicitly.
+var compactVerboseObjectFields = map[string]bool{
+	"description": true,
+	"comments":    true,
+	"attachments": true,
+}
+
 // compactFields keeps only the most important fields for agent consumption.
 // For arrays: allowlist of high-gravity fields (no descriptions).
 // For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
@@ -1194,7 +1205,7 @@ func compactListFields(items []map[string]any) json.RawMessage {
 		keyCounts := map[string]int{}
 		for _, item := range items {
 			for k, v := range item {
-				if compactVerboseFields[k] || !isCompactScalar(v) {
+				if compactVerboseListFields[k] || !isCompactScalar(v) {
 					continue
 				}
 				keyCounts[k]++
@@ -1247,12 +1258,15 @@ func isCompactScalar(v any) bool {
 	}
 }
 
-// compactObjectFields strips known-verbose fields from single-object responses.
-// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+// compactObjectFields strips known-verbose metadata fields from single-object
+// responses. The blocklist deliberately excludes "body"/"content"/"html"/
+// "markdown" — those fields are payload on `get` commands and stripping them
+// under `--agent`/`--compact` is a silent loss; agents who want to omit them
+// can pass `--select` to specify only the fields they need.
 func compactObjectFields(obj map[string]any) json.RawMessage {
 	compact := map[string]any{}
 	for k, v := range obj {
-		if !compactVerboseFields[k] {
+		if !compactVerboseObjectFields[k] {
 			compact[k] = v
 		}
 	}
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
index 662fafb7..23502a66 100644
--- a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -921,14 +921,25 @@ func extractResponseData(data json.RawMessage) json.RawMessage {
 	}
 }
 
-// compactVerboseFields are the prose-shaped fields stripped by both the
-// list and single-object compact paths. Centralised so the contract stays
-// in lockstep across both helpers.
-var compactVerboseFields = map[string]bool{
+// compactVerboseListFields are prose-shaped fields stripped from list-item
+// projections. On lists, "body"/"content"/"html"/"markdown" are verbose
+// noise and the row's identity is carried by id/name/title/etc.
+var compactVerboseListFields = map[string]bool{
 	"description": true, "body": true, "content": true,
 	"comments": true, "attachments": true, "html": true, "markdown": true,
 }
 
+// compactVerboseObjectFields are metadata fields stripped from single-object
+// responses. "body"/"content"/"html"/"markdown" are intentionally absent:
+// for a `get` command those fields are the primary payload, and stripping
+// them under `--agent`/`--compact` silently emits a useless envelope.
+// Use `--select` to drop them explicitly.
+var compactVerboseObjectFields = map[string]bool{
+	"description": true,
+	"comments":    true,
+	"attachments": true,
+}
+
 // compactFields keeps only the most important fields for agent consumption.
 // For arrays: allowlist of high-gravity fields (no descriptions).
 // For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
@@ -993,7 +1004,7 @@ func compactListFields(items []map[string]any) json.RawMessage {
 		keyCounts := map[string]int{}
 		for _, item := range items {
 			for k, v := range item {
-				if compactVerboseFields[k] || !isCompactScalar(v) {
+				if compactVerboseListFields[k] || !isCompactScalar(v) {
 					continue
 				}
 				keyCounts[k]++
@@ -1046,12 +1057,15 @@ func isCompactScalar(v any) bool {
 	}
 }
 
-// compactObjectFields strips known-verbose fields from single-object responses.
-// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+// compactObjectFields strips known-verbose metadata fields from single-object
+// responses. The blocklist deliberately excludes "body"/"content"/"html"/
+// "markdown" — those fields are payload on `get` commands and stripping them
+// under `--agent`/`--compact` is a silent loss; agents who want to omit them
+// can pass `--select` to specify only the fields they need.
 func compactObjectFields(obj map[string]any) json.RawMessage {
 	compact := map[string]any{}
 	for k, v := range obj {
-		if !compactVerboseFields[k] {
+		if !compactVerboseObjectFields[k] {
 			compact[k] = v
 		}
 	}
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 5826f23a..85b8e7df 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -784,14 +784,25 @@ func extractResponseData(data json.RawMessage) json.RawMessage {
 	}
 }
 
-// compactVerboseFields are the prose-shaped fields stripped by both the
-// list and single-object compact paths. Centralised so the contract stays
-// in lockstep across both helpers.
-var compactVerboseFields = map[string]bool{
+// compactVerboseListFields are prose-shaped fields stripped from list-item
+// projections. On lists, "body"/"content"/"html"/"markdown" are verbose
+// noise and the row's identity is carried by id/name/title/etc.
+var compactVerboseListFields = map[string]bool{
 	"description": true, "body": true, "content": true,
 	"comments": true, "attachments": true, "html": true, "markdown": true,
 }
 
+// compactVerboseObjectFields are metadata fields stripped from single-object
+// responses. "body"/"content"/"html"/"markdown" are intentionally absent:
+// for a `get` command those fields are the primary payload, and stripping
+// them under `--agent`/`--compact` silently emits a useless envelope.
+// Use `--select` to drop them explicitly.
+var compactVerboseObjectFields = map[string]bool{
+	"description": true,
+	"comments":    true,
+	"attachments": true,
+}
+
 // compactFields keeps only the most important fields for agent consumption.
 // For arrays: allowlist of high-gravity fields (no descriptions).
 // For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
@@ -856,7 +867,7 @@ func compactListFields(items []map[string]any) json.RawMessage {
 		keyCounts := map[string]int{}
 		for _, item := range items {
 			for k, v := range item {
-				if compactVerboseFields[k] || !isCompactScalar(v) {
+				if compactVerboseListFields[k] || !isCompactScalar(v) {
 					continue
 				}
 				keyCounts[k]++
@@ -909,12 +920,15 @@ func isCompactScalar(v any) bool {
 	}
 }
 
-// compactObjectFields strips known-verbose fields from single-object responses.
-// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+// compactObjectFields strips known-verbose metadata fields from single-object
+// responses. The blocklist deliberately excludes "body"/"content"/"html"/
+// "markdown" — those fields are payload on `get` commands and stripping them
+// under `--agent`/`--compact` is a silent loss; agents who want to omit them
+// can pass `--select` to specify only the fields they need.
 func compactObjectFields(obj map[string]any) json.RawMessage {
 	compact := map[string]any{}
 	for k, v := range obj {
-		if !compactVerboseFields[k] {
+		if !compactVerboseObjectFields[k] {
 			compact[k] = v
 		}
 	}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index c2b796cd..9000da76 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -791,14 +791,25 @@ func extractResponseData(data json.RawMessage) json.RawMessage {
 	}
 }
 
-// compactVerboseFields are the prose-shaped fields stripped by both the
-// list and single-object compact paths. Centralised so the contract stays
-// in lockstep across both helpers.
-var compactVerboseFields = map[string]bool{
+// compactVerboseListFields are prose-shaped fields stripped from list-item
+// projections. On lists, "body"/"content"/"html"/"markdown" are verbose
+// noise and the row's identity is carried by id/name/title/etc.
+var compactVerboseListFields = map[string]bool{
 	"description": true, "body": true, "content": true,
 	"comments": true, "attachments": true, "html": true, "markdown": true,
 }
 
+// compactVerboseObjectFields are metadata fields stripped from single-object
+// responses. "body"/"content"/"html"/"markdown" are intentionally absent:
+// for a `get` command those fields are the primary payload, and stripping
+// them under `--agent`/`--compact` silently emits a useless envelope.
+// Use `--select` to drop them explicitly.
+var compactVerboseObjectFields = map[string]bool{
+	"description": true,
+	"comments":    true,
+	"attachments": true,
+}
+
 // compactFields keeps only the most important fields for agent consumption.
 // For arrays: allowlist of high-gravity fields (no descriptions).
 // For single objects: blocklist that strips known-verbose fields (descriptions, comments, etc.).
@@ -863,7 +874,7 @@ func compactListFields(items []map[string]any) json.RawMessage {
 		keyCounts := map[string]int{}
 		for _, item := range items {
 			for k, v := range item {
-				if compactVerboseFields[k] || !isCompactScalar(v) {
+				if compactVerboseListFields[k] || !isCompactScalar(v) {
 					continue
 				}
 				keyCounts[k]++
@@ -916,12 +927,15 @@ func isCompactScalar(v any) bool {
 	}
 }
 
-// compactObjectFields strips known-verbose fields from single-object responses.
-// Uses a blocklist so it works across all API domains (project management, payments, CRM, etc.).
+// compactObjectFields strips known-verbose metadata fields from single-object
+// responses. The blocklist deliberately excludes "body"/"content"/"html"/
+// "markdown" — those fields are payload on `get` commands and stripping them
+// under `--agent`/`--compact` is a silent loss; agents who want to omit them
+// can pass `--select` to specify only the fields they need.
 func compactObjectFields(obj map[string]any) json.RawMessage {
 	compact := map[string]any{}
 	for k, v := range obj {
-		if !compactVerboseFields[k] {
+		if !compactVerboseObjectFields[k] {
 			compact[k] = v
 		}
 	}

← fe4a5ec3 feat(cli): catalog auth_env_vars declares canonical credenti  ·  back to Cli Printing Press  ·  fix(cli): synthesize undeclared path placeholders in OpenAPI bf9014d4 →