Files
pad/internal/cli/item_summary_test.go
T
xarmian 304673faa1 feat(decision): typed-decision storage and async runner (TASK-3117) (#1417)
* feat(store): item_decisions + decision_jobs schema (TASK-3117)

Answers keyed on (item_id, question_set, question_key, state_hash) with
item_seq kept for the audit; a private per-(item, question_set) job queue
with generation-based coalescing and lease reclaim, in place of the
public event outbox. NUL census: three columns excluded with reasons,
fifteen id/timestamp/enum columns baselined.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(decision): job queue store layer + Runner with state-hash idempotency (TASK-3117)

Item create, item update and comment create enqueue a coalesced job per
applicable question set in their own transaction; nothing waits on the
network. The Runner builds one serialised state (title, collection,
fields, bounded body, last RecentTrailWindow comments), hashes those
exact bytes, and sends the same bytes as json.RawMessage.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* test(decision): queue, runner and read pins; move enqueues (TASK-3117)

Move is a door (ruling 3, keyed on the target collection's sets). The
three system fan-out rewrites are named as non-doors at their sites.
Tests pin: each door enqueues and coalesces; nil resolver writes no row;
lease reclaim; mid-evaluation re-enqueue survives completion; backoff;
latest-per-key read; hashed bytes == wire bytes; a comment re-evaluates
at an unchanged seq; reorder / parent link / role assignment move seq
but do not re-evaluate; current flips false after a comment.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(server): decision tick, read API, decisions on item GET (TASK-3117)

Boot builds the provider from one shared resolver (also used by
`pad server info`); a nil provider means no resolver, no job rows, no
tick. GET /items/{ref}/decisions returns the latest answer per question
with current=true only when computed from the item's present state; the
single-item GET and the agent view carry `decisions` when non-empty. A
shutdown releases in-flight claims without counting a failed attempt.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* test(decision): cancelled pass releases; agent view carries decisions (TASK-3117)

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* feat(store): comment edit/delete enqueue; mid-call cancel test (TASK-3117)

Comment edit (when the body changed) and comment delete change the
hashed trail, so they are doors under ruling 3's rule. Adds the
mid-call shutdown test that mutant M20 showed was missing, and the
decisions endpoint to CLAUDE.md.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): never send an item in a soft-deleted workspace or collection (TASK-3117)

GetItem filters only the item's own soft delete, so a job owed for an
item whose workspace or collection was soft-deleted would have sent that
content to the third-party provider during the restore window. The
runner now checks three-way liveness (the reminder tick's rule) before
building state, and drops such a job.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): drop is generation-matched; inapplicable sets are not asked (TASK-3117)

Codex round 2. P1: DropDecisionJob deleted the row whatever its
generation, so a write that landed while a run was hitting its retry
limit (or a gone item) was lost; it now deletes only the claimed
generation and otherwise releases, as CompleteDecisionJob does. P2: a
job owed before a move still named the old collection's scoped set;
Evaluate re-checks applicability against the item's current collection
and the job is dropped.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(store): idempotency counts only the asked question keys (TASK-3117)

Codex round 3. The completeness check counted every stored key at the
state, so a RENAMED key kept the total equal and the set read complete;
the new key was never asked at an unchanged state. The comment claiming
over-counting was only possible in the safe direction was wrong and is
replaced.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): claim one job at a time; restore enqueues; recheck before ask (TASK-3117)

Codex round 4. Claiming a whole pass up front let the last job wait out
its lease behind earlier provider calls (worst case ~330s per job) and be
re-claimed elsewhere; jobs are now claimed one at a time and the lease
comment carries the arithmetic. Restore is a door: a job dropped while
the item was deleted left it stale with nothing scheduled. Liveness is
re-checked as the last statement before the send, with the residual
window stated. Collection rename / field migration not enqueuing is the
ruled fan-out gap and stays.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): hold jobs of a soft-deleted workspace for its restore (TASK-3117)

Codex round 5: a job processed while its workspace was soft-deleted was
dropped, and a workspace restore (a fan-out, so it enqueues nothing)
never brought the work back. Such jobs are now held: the claim scan
skips them and they resume on restore; a purge cascades them away.
Collections have no restore path and still drop. Liveness is one guard,
the last statement before the send; the earlier copy in State was
redundant (mutant M26 survived its removal) and is removed.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* fix(decision): answers are keyed on question+model; outages never drop work (TASK-3117)

Codex round 6. An answer is now current only for the question definition
and model that produced it (question_hash, per key): rewording a question
or re-pinning the model at an unchanged item state re-asks. Jobs are
dropped only after repeated PERMANENT refusals (a 4xx other than 429);
transient failures back off to an hourly cap and are never dropped, so a
provider outage loses no owed evaluation. Adds the RunOnce-level test for
a workspace deleted mid-job (mutant M35 survived without it). The empty
production registry is the contract ("ships the rail with an empty
registry"), not a defect.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd

* docs(store): name workspace import as a non-door for decisions (TASK-3117)

Codex round 7. Import is the one item-creation path that bypasses
createItemTxWithID; it stays off the decision queue for the same
fan-out reason SPEC-3 keeps it off events/1. Named at the site and in
CLAUDE.md, and raised to the lead as an open policy question.

Claude-Session: https://claude.ai/code/session_01ByMjAhvdWgbAWWeGE9jwWd
2026-09-21 20:46:41 -04:00

210 lines
7.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)
}
}
func TestToItemAgentView_KeepsWorkContextAndDropsStoragePlumbing(t *testing.T) {
item := models.Item{
ID: "item-uuid",
WorkspaceID: "workspace-uuid",
CollectionID: "collection-uuid",
Title: "Implement the compact reader",
Slug: "implement-compact-reader",
Ref: "TASK-9",
Content: "The complete body an agent needs.",
ContentState: models.ContentOutcomeAppliedPendingFlush,
Seq: 42,
Fields: `{"status":"in-progress","estimate":3,"implementation_notes":[{"summary":"Preserve this execution context"}],"decision_log":[{"decision":"Use an additive projection"}]}`,
Tags: `["agents"]`,
CollectionSlug: "tasks",
AssignedUserID: strPtr("user-uuid"),
ImplementationNotes: []models.ItemImplementationNote{
{Summary: "Preserve this execution context"},
},
DecisionLog: []models.ItemDecisionLogEntry{
{Decision: "Use an additive projection"},
},
}
b, err := json.Marshal(ToItemAgentView(item))
if err != nil {
t.Fatalf("marshal: %v", err)
}
js := string(b)
for _, want := range []string{
`"ref":"TASK-9"`,
`"content":"The complete body an agent needs."`,
`"content_state":"applied_pending_flush"`,
`"seq":42`,
`"estimate":3`,
`"status":"in-progress"`,
`"implementation_notes"`,
`"decision_log"`,
} {
if !strings.Contains(js, want) {
t.Errorf("agent view should contain %q; got %s", want, js)
}
}
for _, banned := range []string{
"item-uuid", "workspace-uuid", "collection-uuid", "user-uuid",
`"workspace_id"`, `"collection_id"`, `"content_preview"`,
} {
if strings.Contains(js, banned) {
t.Errorf("agent view should not contain %q; got %s", banned, js)
}
}
if strings.Count(js, "Preserve this execution context") != 1 || strings.Count(js, "Use an additive projection") != 1 {
t.Errorf("hydrated notes and decisions should appear once, 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)
}
}
// The agent projection is the door agents read an item through, and decisions
// exist for agents — so it must carry them (TASK-3117), and omit the member
// entirely when there are none.
func TestToItemAgentView_CarriesDecisions(t *testing.T) {
conf := 0.9
item := models.Item{ID: "i1", Title: "t", Decisions: []models.ItemDecision{{
QuestionSet: "triage", QuestionKey: "urgent", Kind: "choice",
Answer: json.RawMessage(`{"type":"choice","choice":"yes"}`), Confidence: &conf, Current: true,
}}}
b, err := json.Marshal(ToItemAgentView(item))
if err != nil {
t.Fatal(err)
}
var got struct {
Decisions []models.ItemDecision `json:"decisions"`
}
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
if len(got.Decisions) != 1 || got.Decisions[0].QuestionKey != "urgent" || !got.Decisions[0].Current {
t.Fatalf("agent view decisions = %+v (json %s)", got.Decisions, b)
}
b, _ = json.Marshal(ToItemAgentView(models.Item{ID: "i2", Title: "t"}))
var m map[string]json.RawMessage
_ = json.Unmarshal(b, &m)
if _, has := m["decisions"]; has {
t.Fatalf("agent view carries a decisions member for an item with none: %s", b)
}
}