refactor(bootstrap): extend dashboard caps to active_items/active_plans/by_role (TASK-1422) (#541)

* refactor(bootstrap): extend dashboard caps to active_items/active_plans/by_role/suggested_next (TASK-1422)

Implements IDEA-1421 (absorbed into PLAN-1410's v0.4 envelope). Extends
the BootstrapDashboard wrapper from TASK-1413 to cap four more
dashboard sub-arrays with parallel overflow counts, same shape and
semantics as the existing attention/recent_activity caps.

## Struct + caps

Four new int fields on BootstrapDashboard (all `,omitempty`):

  - active_items_overflow_count
  - active_plans_overflow_count
  - by_role_overflow_count
  - suggested_next_overflow_count

Four new cap constants alongside the existing two:

  - bootstrapActiveItemsCap   = 5
  - bootstrapActivePlansCap   = 5
  - bootstrapByRoleCap        = 5
  - bootstrapSuggestedNextCap = 5

capBootstrapDashboard extended with four parallel truncate-and-count
blocks — same shallow-copy mutation pattern, source pointer
untouched (the dashboard endpoint still returns its full-length
arrays per its own contract).

## Tests

TestCapBootstrapDashboard rewritten to cover all six caps under one
contract. `mk` now takes a `dashCounts` struct (Att/Rec/Items/Plans/
Role/Sugg) so each subtest exercises specific caps without populating
the others. Each of the four existing subtests (under-cap-no-overflow,
over-cap-truncates-and-counts-overflow, source-pointer-unchanged,
exact-cap-no-overflow) now asserts the new caps too. Added tiny
assertLen/assertOverflow helpers to keep the per-array assertion
noise from drowning the contract being tested.

bootstrapSectionBytes extended to surface the four new cap-effect
lines when triggered, table-driven so future caps drop in cleanly.

seedBootstrapSizeFixture updated to seed 6 in_progress tasks (was 5
open) so the new active_items cap fires visibly in the per-section
breakdown: "active_items capped: 5 shown, 1 overflow". The status
flip is deliberate — dashboard.active_items filters on
isActiveStatus(), which excludes initial/terminal statuses; open
tasks never appeared in the section.

## Budget

bootstrapSizeBudget 7 KiB → 9 KiB. Note that this is FIXTURE-side
growth, not shape-side regression: the fixture now seeds enough
active items to exercise the new cap (active_items section was 0
bytes when tasks were status=open). The cap itself is purely a
SAVINGS — on docapp it drops active_items from 7 → 5 entries with
overflow_count=2.

Budget history note in handlers_bootstrap_test.go updated with the
TASK-1422 line and an explicit "fixture-side, not shape-side"
explanation so future readers understand why the budget moved up.

## Out of scope

- Slim BootstrapRole projection — TASK-1423.
- Schema label + sort_order trim — TASK-1424.
- ToolSurfaceVersion 0.3 → 0.4 bump — TASK-1418 (final PR).

Parent: PLAN-1410. Resolves IDEA-1421 once merged.

* fix(bootstrap): drop unreachable suggested_next cap (TASK-1422 follow-up)

Address Codex P1 finding on PR #541: `suggested_next_overflow_count`
was unreachable in production responses because
`buildDashboardResponse` already truncates `SuggestedNext` to 3
upstream (see "Take top 3" comment in handlers_dashboard.go:854-858),
while my bootstrap cap was 5. The cap-and-overflow logic could only
have fired against synthetic test state, never against the real
dashboard pipeline.

Two responses to consider:

  1. Lower bootstrap's cap to a number smaller than 3 — defeats the
     upstream design choice (3 IS the intentional limit).
  2. Drop the bootstrap-side cap — clean, no dead surface.

Going with (2). If the upstream cap is ever raised or removed,
that's the moment to add a suggested_next_overflow_count back.

Removed:

  - SuggestedNextOverflowCount field on BootstrapDashboard
  - bootstrapSuggestedNextCap constant
  - The truncate-and-count block in capBootstrapDashboard
  - The suggested_next row in TestCapBootstrapDashboard's dashCounts
    helper and all five subtest assertions
  - The suggested_next entry in bootstrapSectionBytes's cap-line loop

The fixture still seeds tasks and a plan, so the no-cap path on
SuggestedNext is naturally exercised through TestBootstrapSizeBudget.
The godoc on BootstrapDashboard now explicitly calls out the
exclusion + the upstream-cap rationale so a future reader knows
why suggested_next is missing from the otherwise-uniform cap set.

Parent: PLAN-1410 / TASK-1422.

* docs(skill): align SKILL.md dashboard cap description with TASK-1422

Address Codex P3 finding on PR #541: the SKILL.md `Context Loading`
section described only the two original cap fields
(attention_overflow_count, recent_activity_overflow_count). After
TASK-1422 the bootstrap response carries three more
(active_items_overflow_count, active_plans_overflow_count,
by_role_overflow_count), and the agent needs to know to pull the
full set via `pad project dashboard` when any of them are > 0.

Updated the bullet to enumerate all five capped sub-arrays and
state the overflow-field pattern generically rather than per-field.
Same in-PR-sync pattern used for TASK-1413, TASK-1415, TASK-1416.

Parent: PLAN-1410 / TASK-1422.

* docs(bootstrap): fix three stale comments after dropping suggested_next cap (TASK-1422 follow-up)

Address Codex P3 finding on PR #541 round 3: three stale doc strings
referenced the old shape (with suggested_next) after the cap was
dropped in the prior commit. Updated:

1. handlers_bootstrap_test.go budget-history line for TASK-1422 —
   removed `suggested_next` from the cap list and added the
   "deliberately excluded — already capped to 3 upstream" rationale
   so future readers know why the otherwise-uniform cap set is
   missing one.

2. handlers_bootstrap.go BootstrapDashboard godoc — changed
   "two overflow counts" to "five overflow counts (one per capped
   sub-array)".

3. handlers_bootstrap.go capBootstrapDashboard godoc — changed
   "both caps are untriggered" to "all caps are untriggered".

Tidy-up only, no behavior change. Same skill-↔-code sync hygiene
that has been the running theme across PLAN-1410's review loops.

Parent: PLAN-1410 / TASK-1422.

* docs(bootstrap): update remaining stale call-site comment for capBootstrapDashboard (TASK-1422 follow-up)

Final stale-doc cleanup per Codex P3 on PR #541 round 4: the
BuildAgentBootstrap dashboard-wrapping call-site comment still
listed only attention + recent_activity. Updated to enumerate all
five capped sub-arrays for parity with the godoc on
BootstrapDashboard / capBootstrapDashboard.

Same hygiene as the previous commit; no behavior change.

Parent: PLAN-1410 / TASK-1422.
This commit is contained in:
xarmian
2026-05-13 17:10:56 -04:00
committed by GitHub
parent 9c9aac3ea6
commit aaa581b105
3 changed files with 210 additions and 78 deletions
+45 -9
View File
@@ -153,8 +153,8 @@ type AgentBootstrapPlaybookMeta struct {
// BootstrapDashboard is the bootstrap-side dashboard projection. It
// embeds *DashboardResponse so the wire shape stays compatible with the
// `GET /dashboard` endpoint (same field names, same nesting), then adds
// two overflow counts that report how many entries were trimmed from
// the bootstrap's capped views.
// five overflow counts (one per capped sub-array) that report how many
// entries were trimmed from the bootstrap's capped views.
//
// Why a wrapper rather than mutating DashboardResponse: the cap is
// bootstrap-only — `pad project dashboard` and the web UI's dashboard
@@ -168,18 +168,38 @@ type BootstrapDashboard struct {
// RecentActivityOverflowCount mirrors AttentionOverflowCount for the
// recent_activity tail.
RecentActivityOverflowCount int `json:"recent_activity_overflow_count,omitempty"`
// ActiveItemsOverflowCount, ActivePlansOverflowCount, and
// ByRoleOverflowCount cap the three other dashboard sub-arrays
// that grow with workspace state. Same semantics as the
// Attention/RecentActivity counts above: omitted when zero,
// populated with `len(original) - cap` when truncation kicked in.
// PLAN-1410 / TASK-1422 (absorbs IDEA-1421).
//
// `suggested_next` was deliberately NOT added to this set:
// buildDashboardResponse already truncates SuggestedNext to 3
// upstream (see "Take top 3" in handlers_dashboard.go), so a
// bootstrap-side cap of 5 would be unreachable dead code. If the
// upstream cap is ever raised or removed, that's the moment to
// add a suggested_next_overflow_count here.
ActiveItemsOverflowCount int `json:"active_items_overflow_count,omitempty"`
ActivePlansOverflowCount int `json:"active_plans_overflow_count,omitempty"`
ByRoleOverflowCount int `json:"by_role_overflow_count,omitempty"`
}
// bootstrapAttentionCap and bootstrapRecentActivityCap clamp the
// dashboard's `attention` and `recent_activity` arrays in the bootstrap
// Bootstrap caps clamp the per-array sizes in the bootstrap dashboard
// projection. 5 is the practical surfacing depth for an agent greeting
// or status pass — anything beyond is too much for a single response
// to render conversationally; the agent should pivot to the full
// `pad project dashboard` query when the overflow count signals more
// work to consider. PLAN-1410.
// `pad project dashboard` query when an overflow count signals more
// work to consider. PLAN-1410. The first two land in TASK-1413; the
// remaining three (active_items / active_plans / by_role) are TASK-1422
// (IDEA-1421 absorbed). suggested_next is excluded — upstream cap of 3.
const (
bootstrapAttentionCap = 5
bootstrapRecentActivityCap = 5
bootstrapActiveItemsCap = 5
bootstrapActivePlansCap = 5
bootstrapByRoleCap = 5
)
// isCollectionSlugVisible reports whether the named collection survived
@@ -375,8 +395,10 @@ func (s *Server) BuildAgentBootstrap(workspaceID string, user *models.User, r *h
// Dashboard — recreate via the existing handler logic if a request
// context is available, then wrap in BootstrapDashboard so the
// bootstrap-side cap on `attention` and `recent_activity` (with
// overflow counts) doesn't leak into the `GET /dashboard` contract.
// bootstrap-side caps on `attention`, `recent_activity`,
// `active_items`, `active_plans`, and `by_role` (each with its
// `*_overflow_count` companion) don't leak into the
// `GET /dashboard` contract.
if r != nil {
dash, derr := s.buildDashboardResponse(workspaceID, r)
if derr == nil && dash != nil {
@@ -577,7 +599,7 @@ func trimLeadingSpaces(s string) string {
// arrays unchanged. The slice backing arrays are shared — we only
// trim the view, no allocation needed for the truncated portion.
//
// Returns a non-nil *BootstrapDashboard even when both caps are
// Returns a non-nil *BootstrapDashboard even when all caps are
// untriggered, so the agent always sees a consistent shape.
func capBootstrapDashboard(d *DashboardResponse) *BootstrapDashboard {
copied := *d
@@ -590,6 +612,20 @@ func capBootstrapDashboard(d *DashboardResponse) *BootstrapDashboard {
copied.RecentActivity = copied.RecentActivity[:bootstrapRecentActivityCap]
out.RecentActivityOverflowCount = n
}
if n := len(copied.ActiveItems) - bootstrapActiveItemsCap; n > 0 {
copied.ActiveItems = copied.ActiveItems[:bootstrapActiveItemsCap]
out.ActiveItemsOverflowCount = n
}
if n := len(copied.ActivePlans) - bootstrapActivePlansCap; n > 0 {
copied.ActivePlans = copied.ActivePlans[:bootstrapActivePlansCap]
out.ActivePlansOverflowCount = n
}
if n := len(copied.ByRole) - bootstrapByRoleCap; n > 0 {
copied.ByRole = copied.ByRole[:bootstrapByRoleCap]
out.ByRoleOverflowCount = n
}
// SuggestedNext intentionally NOT capped here — see godoc on
// BootstrapDashboard.
return out
}
+164 -68
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TestBootstrapEmptyWorkspace verifies the bootstrap blob returns the
@@ -151,12 +153,17 @@ func TestBootstrapIncludesPlaybookMetadata(t *testing.T) {
// TASK-1412 — 11 KiB (slim BootstrapCollection projection; fixture at 8,992 bytes — collections section dropped from 8,848 to 3,979 bytes)
// TASK-1413 — 8 KiB (dedup top-level recent_activity, drop convention slug, cap dashboard.attention/recent_activity to 5 with overflow counts; fixture at 6,355 bytes — total dropped another 2,637 bytes)
// TASK-1417 — 7 KiB (close-out: bootstrap shape work complete, fixture still at 6,355 bytes; locks in the cumulative -54.2% win with ~12.8% headroom for routine schema reordering)
// TASK-1422 — 9 KiB (extend dashboard caps to active_items/active_plans/by_role; fixture grew to 7,823 bytes after seeding 6 in_progress tasks to exercise the new active_items cap — the growth is fixture-side, not shape-side, and the new caps demonstrably trigger in the per-section breakdown. suggested_next deliberately excluded — already capped to 3 upstream in buildDashboardResponse.)
//
// PLAN-1410 followups land their own wins under their own budget
// ratchets (e.g. IDEA-1421's dashboard sub-array caps). The constant
// intentionally lives next to the test that consumes it so PRs
// touching the bootstrap shape see the budget in the diff.
const bootstrapSizeBudget = 7 * 1024
// Note that the TASK-1422 budget loosening is purely fixture-side: the
// fixture deliberately seeds more `in_progress` items so the
// active_items cap fires under realistic load. The cap itself is
// purely a SAVINGS (clamps unbounded growth in live workspaces). On
// docapp the cap drops active_items from 7 → 5 entries.
//
// The constant intentionally lives next to the test that consumes it
// so PRs touching the bootstrap shape see the budget in the diff.
const bootstrapSizeBudget = 9 * 1024
// TestBootstrapSizeBudget locks in a payload-size budget for the
// bootstrap response so future regressions are caught at PR time.
@@ -233,11 +240,16 @@ func seedBootstrapSizeFixture(t *testing.T, srv *Server, wsSlug string) {
"fields": `{"status":"active","trigger":"manual","invocation_slug":"release","arguments":[{"name":"version","type":"string","required":true}]}`,
})
for i := 0; i < 5; i++ {
// 6 in-progress tasks: 6 > bootstrapActiveItemsCap (5) → exercises
// the active_items cap in the fixture, producing an "active_items
// capped: 5 shown, 1 overflow" line in the per-section breakdown.
// in_progress (not open) because dashboard.active_items filters on
// isActiveStatus(), which excludes initial/terminal statuses.
for i := 0; i < 6; i++ {
createItem(t, srv, wsSlug, "tasks", map[string]interface{}{
"title": fmt.Sprintf("Sample task %d", i),
"content": "Task body — placeholder content to give the dashboard something to summarize.",
"fields": `{"status":"open","priority":"medium"}`,
"fields": `{"status":"in-progress","priority":"medium"}`,
})
}
createItem(t, srv, wsSlug, "plans", map[string]interface{}{
@@ -266,17 +278,26 @@ func bootstrapSectionBytes(b AgentBootstrap) []string {
fmt.Sprintf("dashboard: %d bytes", jsonLen(b.Dashboard)),
}
// Surface the caps' effect when triggered so the trim's value is
// legible from CI output as PLAN-1410's later PRs land.
// legible from CI output as PLAN-1410's PRs land. Each line follows
// the same shape: "<array> capped: N shown, M overflow".
if b.Dashboard != nil {
if b.Dashboard.AttentionOverflowCount > 0 {
lines = append(lines, fmt.Sprintf(
" └─ attention capped: %d shown, %d overflow",
len(b.Dashboard.Attention), b.Dashboard.AttentionOverflowCount))
type capLine struct {
name string
shown int
overflow int
}
if b.Dashboard.RecentActivityOverflowCount > 0 {
lines = append(lines, fmt.Sprintf(
" └─ recent_activity capped: %d shown, %d overflow",
len(b.Dashboard.RecentActivity), b.Dashboard.RecentActivityOverflowCount))
for _, c := range []capLine{
{"attention", len(b.Dashboard.Attention), b.Dashboard.AttentionOverflowCount},
{"recent_activity", len(b.Dashboard.RecentActivity), b.Dashboard.RecentActivityOverflowCount},
{"active_items", len(b.Dashboard.ActiveItems), b.Dashboard.ActiveItemsOverflowCount},
{"active_plans", len(b.Dashboard.ActivePlans), b.Dashboard.ActivePlansOverflowCount},
{"by_role", len(b.Dashboard.ByRole), b.Dashboard.ByRoleOverflowCount},
} {
if c.overflow > 0 {
lines = append(lines, fmt.Sprintf(
" └─ %s capped: %d shown, %d overflow",
c.name, c.shown, c.overflow))
}
}
}
return lines
@@ -295,91 +316,166 @@ func jsonLen(v interface{}) int {
}
// TestCapBootstrapDashboard isolates the bootstrap dashboard cap logic
// from the rest of the bootstrap pipeline so the contract (cap to N,
// surface overflow count, leave the source pointer untouched) doesn't
// drift silently. PLAN-1410 / TASK-1413.
// from the rest of the bootstrap pipeline so the contract (cap to N per
// array, surface overflow count, leave the source pointer untouched)
// doesn't drift silently as new caps are added.
//
// PLAN-1410 introduced caps on attention + recent_activity (TASK-1413)
// and extended them to active_items / active_plans / by_role
// (TASK-1422, absorbing IDEA-1421). `suggested_next` is deliberately
// excluded — it's already capped to 3 upstream in buildDashboardResponse,
// making a bootstrap-side cap unreachable in production. This test
// covers all five live caps under the same contract.
func TestCapBootstrapDashboard(t *testing.T) {
// Helper: make a DashboardResponse with N attention + M recent_activity.
mk := func(attN, recN int) *DashboardResponse {
attention := make([]DashboardAttention, attN)
for i := range attention {
attention[i] = DashboardAttention{Type: "stalled", ItemRef: fmt.Sprintf("TASK-%d", i)}
// dashCounts is the per-array size knob the test uses to construct
// a DashboardResponse with arbitrary fill levels. Each field can be
// set independently so subtests can exercise specific caps without
// populating the others — keeps the assertions for any one cap
// uncoupled from the noise of the others.
type dashCounts struct {
Att, Rec, Items, Plans, Role int
}
mk := func(c dashCounts) *DashboardResponse {
d := &DashboardResponse{
Attention: make([]DashboardAttention, c.Att),
RecentActivity: make([]DashboardActivity, c.Rec),
ActiveItems: make([]DashboardActiveItem, c.Items),
ActivePlans: make([]DashboardPlan, c.Plans),
ByRole: make([]store.RoleBreakdown, c.Role),
}
recent := make([]DashboardActivity, recN)
for i := range recent {
recent[i] = DashboardActivity{Action: "updated", ItemSlug: fmt.Sprintf("item-%d", i)}
// Populate with identifying values so the cap's slice header
// retains a defined order — easier to spot index-shifting bugs.
for i := range d.Attention {
d.Attention[i] = DashboardAttention{Type: "stalled", ItemRef: fmt.Sprintf("TASK-%d", i)}
}
return &DashboardResponse{Attention: attention, RecentActivity: recent}
for i := range d.RecentActivity {
d.RecentActivity[i] = DashboardActivity{Action: "updated", ItemSlug: fmt.Sprintf("item-%d", i)}
}
for i := range d.ActiveItems {
d.ActiveItems[i] = DashboardActiveItem{Slug: fmt.Sprintf("active-%d", i)}
}
for i := range d.ActivePlans {
d.ActivePlans[i] = DashboardPlan{Slug: fmt.Sprintf("plan-%d", i)}
}
for i := range d.ByRole {
d.ByRole[i] = store.RoleBreakdown{}
}
return d
}
t.Run("under-cap-no-overflow", func(t *testing.T) {
d := mk(2, 3)
d := mk(dashCounts{Att: 2, Rec: 3, Items: 1, Plans: 1, Role: 2})
out := capBootstrapDashboard(d)
if out == nil {
t.Fatal("expected non-nil result even when nothing trimmed")
}
if got := len(out.Attention); got != 2 {
t.Errorf("attention len = %d, want 2 (under cap, no trim)", got)
}
if out.AttentionOverflowCount != 0 {
t.Errorf("attention_overflow_count = %d, want 0", out.AttentionOverflowCount)
}
if got := len(out.RecentActivity); got != 3 {
t.Errorf("recent_activity len = %d, want 3", got)
}
if out.RecentActivityOverflowCount != 0 {
t.Errorf("recent_activity_overflow_count = %d, want 0", out.RecentActivityOverflowCount)
}
assertLen(t, "attention", len(out.Attention), 2)
assertOverflow(t, "attention", out.AttentionOverflowCount, 0)
assertLen(t, "recent_activity", len(out.RecentActivity), 3)
assertOverflow(t, "recent_activity", out.RecentActivityOverflowCount, 0)
assertLen(t, "active_items", len(out.ActiveItems), 1)
assertOverflow(t, "active_items", out.ActiveItemsOverflowCount, 0)
assertLen(t, "active_plans", len(out.ActivePlans), 1)
assertOverflow(t, "active_plans", out.ActivePlansOverflowCount, 0)
assertLen(t, "by_role", len(out.ByRole), 2)
assertOverflow(t, "by_role", out.ByRoleOverflowCount, 0)
})
t.Run("over-cap-truncates-and-counts-overflow", func(t *testing.T) {
d := mk(bootstrapAttentionCap+8, bootstrapRecentActivityCap+3)
d := mk(dashCounts{
Att: bootstrapAttentionCap + 8,
Rec: bootstrapRecentActivityCap + 3,
Items: bootstrapActiveItemsCap + 4,
Plans: bootstrapActivePlansCap + 2,
Role: bootstrapByRoleCap + 1,
})
out := capBootstrapDashboard(d)
if got := len(out.Attention); got != bootstrapAttentionCap {
t.Errorf("attention len = %d, want %d (capped)", got, bootstrapAttentionCap)
}
if out.AttentionOverflowCount != 8 {
t.Errorf("attention_overflow_count = %d, want 8", out.AttentionOverflowCount)
}
if got := len(out.RecentActivity); got != bootstrapRecentActivityCap {
t.Errorf("recent_activity len = %d, want %d (capped)", got, bootstrapRecentActivityCap)
}
if out.RecentActivityOverflowCount != 3 {
t.Errorf("recent_activity_overflow_count = %d, want 3", out.RecentActivityOverflowCount)
}
assertLen(t, "attention", len(out.Attention), bootstrapAttentionCap)
assertOverflow(t, "attention", out.AttentionOverflowCount, 8)
assertLen(t, "recent_activity", len(out.RecentActivity), bootstrapRecentActivityCap)
assertOverflow(t, "recent_activity", out.RecentActivityOverflowCount, 3)
assertLen(t, "active_items", len(out.ActiveItems), bootstrapActiveItemsCap)
assertOverflow(t, "active_items", out.ActiveItemsOverflowCount, 4)
assertLen(t, "active_plans", len(out.ActivePlans), bootstrapActivePlansCap)
assertOverflow(t, "active_plans", out.ActivePlansOverflowCount, 2)
assertLen(t, "by_role", len(out.ByRole), bootstrapByRoleCap)
assertOverflow(t, "by_role", out.ByRoleOverflowCount, 1)
})
t.Run("source-pointer-unchanged", func(t *testing.T) {
// Defensive contract: callers downstream of buildDashboardResponse
// (the dashboard endpoint itself) must see their full-length
// arrays. The cap mutates a shallow copy.
d := mk(bootstrapAttentionCap+5, bootstrapRecentActivityCap+5)
origAttLen := len(d.Attention)
origRecLen := len(d.RecentActivity)
d := mk(dashCounts{
Att: bootstrapAttentionCap + 5,
Rec: bootstrapRecentActivityCap + 5,
Items: bootstrapActiveItemsCap + 5,
Plans: bootstrapActivePlansCap + 5,
Role: bootstrapByRoleCap + 5,
})
want := struct{ att, rec, items, plans, role int }{
att: len(d.Attention),
rec: len(d.RecentActivity),
items: len(d.ActiveItems),
plans: len(d.ActivePlans),
role: len(d.ByRole),
}
_ = capBootstrapDashboard(d)
if got := len(d.Attention); got != origAttLen {
t.Errorf("source Attention mutated: len = %d, want %d", got, origAttLen)
if got := len(d.Attention); got != want.att {
t.Errorf("source Attention mutated: len = %d, want %d", got, want.att)
}
if got := len(d.RecentActivity); got != origRecLen {
t.Errorf("source RecentActivity mutated: len = %d, want %d", got, origRecLen)
if got := len(d.RecentActivity); got != want.rec {
t.Errorf("source RecentActivity mutated: len = %d, want %d", got, want.rec)
}
if got := len(d.ActiveItems); got != want.items {
t.Errorf("source ActiveItems mutated: len = %d, want %d", got, want.items)
}
if got := len(d.ActivePlans); got != want.plans {
t.Errorf("source ActivePlans mutated: len = %d, want %d", got, want.plans)
}
if got := len(d.ByRole); got != want.role {
t.Errorf("source ByRole mutated: len = %d, want %d", got, want.role)
}
})
t.Run("exact-cap-no-overflow", func(t *testing.T) {
// Boundary: len == cap should not flag overflow.
d := mk(bootstrapAttentionCap, bootstrapRecentActivityCap)
d := mk(dashCounts{
Att: bootstrapAttentionCap,
Rec: bootstrapRecentActivityCap,
Items: bootstrapActiveItemsCap,
Plans: bootstrapActivePlansCap,
Role: bootstrapByRoleCap,
})
out := capBootstrapDashboard(d)
if out.AttentionOverflowCount != 0 {
t.Errorf("attention_overflow_count at exact cap = %d, want 0", out.AttentionOverflowCount)
}
if out.RecentActivityOverflowCount != 0 {
t.Errorf("recent_activity_overflow_count at exact cap = %d, want 0", out.RecentActivityOverflowCount)
}
assertOverflow(t, "attention", out.AttentionOverflowCount, 0)
assertOverflow(t, "recent_activity", out.RecentActivityOverflowCount, 0)
assertOverflow(t, "active_items", out.ActiveItemsOverflowCount, 0)
assertOverflow(t, "active_plans", out.ActivePlansOverflowCount, 0)
assertOverflow(t, "by_role", out.ByRoleOverflowCount, 0)
})
}
// assertLen and assertOverflow are tiny helpers used by
// TestCapBootstrapDashboard to keep the per-array assertion noise from
// drowning the actual contract being tested. Both call t.Helper() so
// failure lines point at the calling subtest, not at this file.
func assertLen(t *testing.T, name string, got, want int) {
t.Helper()
if got != want {
t.Errorf("%s len = %d, want %d", name, got, want)
}
}
func assertOverflow(t *testing.T, name string, got, want int) {
t.Helper()
if got != want {
t.Errorf("%s_overflow_count = %d, want %d", name, got, want)
}
}
// TestPlaybookSummaryPrefersFirstParagraph isolates the summary extraction
// from the bootstrap path so the rule (skip headings, take first non-empty
// paragraph, cap at ~240 chars) doesn't drift silently.
+1 -1
View File
@@ -35,7 +35,7 @@ The returned `AgentBootstrap` blob carries everything the skill needs to start a
- `conventions [...]` — full bodies of `trigger=always, status=active` items. **Must-follow project rules.**
- `roles [...]` — agent roles configured in the workspace
- `playbooks [...]` — METADATA ONLY: `ref`, `title`, `slug`, `invocation_slug`, `trigger`, `scope`, `status`, `has_arguments`, `summary`. Full bodies load on invocation via `pad playbook show <slug>`.
- `dashboard {...}` — active items, attention, suggested next, recent activity. `attention` and `recent_activity` are capped to 5 entries each; `attention_overflow_count` and `recent_activity_overflow_count` report how many were trimmed (use `pad project dashboard` to pull the full set when overflow > 0).
- `dashboard {...}` — active items, attention, suggested next, recent activity. Five sub-arrays are capped to 5 entries each (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`); each pairs with a `<name>_overflow_count` int field surfaced when truncation kicked in. Use `pad project dashboard` to pull the full set when any overflow > 0.
If the conventions list includes items, treat them as project rules you must follow. The vocabulary depends on the workspace domain — a software workspace ships rules like "use conventional commit format," a hiring workspace ships rules like "anonymize candidate names in exports," a research workspace ships rules like "always cite sources." Follow whatever the workspace has configured.