mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
cf5eb8dd3a
`pad item list --format json` returned the full models.Item shape — including each item's rich markdown `content` body (~52% of the bytes) plus UUID plumbing and duplicate join fields — with no default limit, so a bare agent list dumped ~1.4MB (all collections) or 5.3MB (--all) into context. The single biggest agent-token lever. CLI: - JSON output now defaults to a token-light ItemSummary projection: `content` → short `content_preview`, UUIDs (id/workspace_id/collection_id/*_user_id/ parent_id/agent_role_id) and duplicate collection/parent join fields dropped, `fields`/`tags` emitted as nested JSON. ~71% smaller on a real workspace. - `--full` opt-in flag restores the complete models.Item shape. - Default limit (200) + hard-max clamp (1000) so --all/huge lists can't dump unboundedly; a stderr note fires when a table result is capped. MCP: - pad_item.list is now a custom action that injects a default limit (50) and clamps an oversized one (max 300), mirroring the backlinks default/max, so a bare agent list stays bounded on both dispatchers. - ToolSurfaceVersion 0.8 → 0.9 (list result shape + limit behavior change). Server: - Hard-max backstop clamp (1000) on an explicit `?limit=` at the item-list request boundary; no default (internal ListItems callers that fetch every row are untouched). rawJSONOrNil guards against a malformed stored Fields/Tags value breaking the whole list marshal (falls back to a JSON string).
124 lines
4.3 KiB
Go
124 lines
4.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
func intPtr(n int) *int { return &n }
|
|
func strPtr(s string) *string { return &s }
|
|
|
|
// TestToItemSummary_DropsContentAndUUIDs verifies the default summary
|
|
// projection replaces the rich body with a preview and drops the UUID
|
|
// plumbing / duplicate join fields. TASK-2000.
|
|
func TestToItemSummary_DropsContentAndUUIDs(t *testing.T) {
|
|
item := models.Item{
|
|
ID: "uuid-1",
|
|
WorkspaceID: "ws-uuid",
|
|
CollectionID: "coll-uuid",
|
|
Title: "Fix the widget",
|
|
Slug: "fix-the-widget",
|
|
Ref: "TASK-5",
|
|
Content: "# Heading\n\nThe body has real detail that costs tokens.",
|
|
Fields: `{"status":"open","priority":"high"}`,
|
|
Tags: `["bug","ui"]`,
|
|
CollectionSlug: "tasks",
|
|
CollectionName: "Tasks",
|
|
ItemNumber: intPtr(5),
|
|
AssignedUserID: strPtr("user-uuid"),
|
|
AssignedUserName: "Dave",
|
|
AgentRoleSlug: "implementer",
|
|
ParentRef: "PLAN-2",
|
|
ParentTitle: "Big plan",
|
|
}
|
|
|
|
sum := ToItemSummary(item)
|
|
|
|
// Marshal to JSON and confirm the heavy/duplicate fields are gone.
|
|
b, err := json.Marshal(sum)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
js := string(b)
|
|
|
|
for _, banned := range []string{"uuid-1", "ws-uuid", "coll-uuid", "user-uuid", `"content"`, "real detail", "Big plan", "\"id\""} {
|
|
if strings.Contains(js, banned) {
|
|
t.Errorf("summary JSON should not contain %q; got %s", banned, js)
|
|
}
|
|
}
|
|
if sum.Ref != "TASK-5" || sum.Title != "Fix the widget" || sum.CollectionSlug != "tasks" {
|
|
t.Errorf("summary dropped a field it should keep: %+v", sum)
|
|
}
|
|
if sum.ContentPreview == "" || strings.Contains(sum.ContentPreview, "#") {
|
|
t.Errorf("content_preview should be a heading-stripped snippet, got %q", sum.ContentPreview)
|
|
}
|
|
if sum.AssignedUser != "Dave" || sum.AgentRole != "implementer" || sum.ParentRef != "PLAN-2" {
|
|
t.Errorf("summary dropped a human-readable field: %+v", sum)
|
|
}
|
|
// fields/tags should be nested JSON, not escaped strings.
|
|
if !strings.Contains(js, `"fields":{"status":"open"`) {
|
|
t.Errorf("fields should be a nested object, got %s", js)
|
|
}
|
|
if !strings.Contains(js, `"tags":["bug","ui"]`) {
|
|
t.Errorf("tags should be a nested array, got %s", js)
|
|
}
|
|
}
|
|
|
|
// TestContentPreview_TruncatesAndStrips checks the preview helper.
|
|
func TestContentPreview_TruncatesAndStrips(t *testing.T) {
|
|
if got := contentPreview(""); got != "" {
|
|
t.Errorf("empty content should yield empty preview, got %q", got)
|
|
}
|
|
if got := contentPreview("\n\n## Title line\nmore"); got != "Title line" {
|
|
t.Errorf("preview should take first non-blank line, heading-stripped, got %q", got)
|
|
}
|
|
long := strings.Repeat("a", contentPreviewLimit+50)
|
|
got := contentPreview(long)
|
|
if !strings.HasSuffix(got, "…") {
|
|
t.Errorf("over-long preview should be ellipsized, got %q", got)
|
|
}
|
|
if len([]rune(got)) > contentPreviewLimit+1 {
|
|
t.Errorf("preview should be capped near %d runes, got %d", contentPreviewLimit, len([]rune(got)))
|
|
}
|
|
}
|
|
|
|
// TestRawJSONOrNil_OmitsEmpty confirms empty containers are dropped so
|
|
// the summary stays lean and never emits invalid RawMessage("").
|
|
func TestRawJSONOrNil_OmitsEmpty(t *testing.T) {
|
|
for _, empty := range []string{"", "{}", "[]", "null", " "} {
|
|
if got := rawJSONOrNil(empty); got != nil {
|
|
t.Errorf("rawJSONOrNil(%q) = %s, want nil", empty, got)
|
|
}
|
|
}
|
|
if got := rawJSONOrNil(`{"a":1}`); string(got) != `{"a":1}` {
|
|
t.Errorf("rawJSONOrNil should pass through real JSON, got %s", got)
|
|
}
|
|
// Malformed stored value must not break marshal — falls back to a
|
|
// valid JSON string rather than an invalid RawMessage.
|
|
got := rawJSONOrNil(`{not valid`)
|
|
if got == nil {
|
|
t.Fatal("malformed value should fall back, not drop")
|
|
}
|
|
if !json.Valid(got) {
|
|
t.Errorf("fallback must be valid JSON, got %s", got)
|
|
}
|
|
if string(got) != `"{not valid"` {
|
|
t.Errorf("malformed value should be emitted as a JSON string, got %s", got)
|
|
}
|
|
}
|
|
|
|
// TestToItemSummaries_EmptyIsNonNil ensures a nil input encodes as `[]`.
|
|
func TestToItemSummaries_EmptyIsNonNil(t *testing.T) {
|
|
out := ToItemSummaries(nil)
|
|
if out == nil {
|
|
t.Fatal("ToItemSummaries(nil) should be non-nil")
|
|
}
|
|
b, _ := json.Marshal(out)
|
|
if string(b) != "[]" {
|
|
t.Errorf("empty summaries should encode as [], got %s", b)
|
|
}
|
|
}
|