Files
pad/internal/store/activities_test.go
T
xarmian c846cff4fd feat(project): agent-accessible activity feed (pad project activity + MCP action) (#877)
* feat(project): agent-accessible activity feed (pad project activity + MCP action)

Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.

- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
  backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
  (handler parse + store SQL clause) so limit/actor/since behave
  identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
  instructions.md) and add a SKILL.md querying-guidance line.

Tests: store since-filter test, HTTP dispatch test, catalog action test.

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

* fix(mcp): mark pad_project.activity read-only in tool surface

Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:26:41 -04:00

646 lines
20 KiB
Go

package store
import (
"encoding/json"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
func TestCreateActivityDebounced_NonUpdateActions(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// "created" should always produce a new row, never debounce
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "created",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("first CreateActivityDebounced error: %v", err)
}
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "created",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("second CreateActivityDebounced error: %v", err)
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "created"})
if len(activities) != 2 {
t.Errorf("non-update actions should not be debounced: expected 2, got %d", len(activities))
}
}
func TestCreateActivityDebounced_CoalescesUpdates(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// First "updated" activity
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
Metadata: `{"changes":"status: open -> active"}`,
})
if err != nil {
t.Fatalf("first update error: %v", err)
}
// Second "updated" activity within cooldown — should coalesce
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
Metadata: `{"changes":"priority: low -> high"}`,
})
if err != nil {
t.Fatalf("second update error: %v", err)
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities) != 1 {
t.Fatalf("expected 1 coalesced activity, got %d", len(activities))
}
// Verify metadata was merged
var meta map[string]interface{}
if err := json.Unmarshal([]byte(activities[0].Metadata), &meta); err != nil {
t.Fatalf("failed to parse metadata: %v", err)
}
changes, _ := meta["changes"].(string)
if changes != "status: open -> active; priority: low -> high" {
t.Errorf("expected merged changes, got %q", changes)
}
}
// TestListWorkspaceActivity_SinceFilter pins TASK-2018's server-side
// `since` filter: entries created before the cutoff are excluded, and a
// future cutoff excludes everything. Timestamp-agnostic (uses past/future
// relative to now) so it doesn't flake on clock skew.
func TestListWorkspaceActivity_SinceFilter(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
for _, action := range []string{"created", "updated"} {
if _, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: action,
Actor: "user",
Source: "web",
}); err != nil {
t.Fatalf("create %s activity: %v", action, err)
}
}
// No since → both entries.
all, err := s.ListWorkspaceActivity(ws.ID, models.ActivityListParams{})
if err != nil {
t.Fatalf("list without since: %v", err)
}
if len(all) != 2 {
t.Fatalf("expected 2 activities without since filter, got %d", len(all))
}
// Since in the past → both entries.
past, err := s.ListWorkspaceActivity(ws.ID, models.ActivityListParams{
Since: time.Now().Add(-1 * time.Hour),
})
if err != nil {
t.Fatalf("list with past since: %v", err)
}
if len(past) != 2 {
t.Errorf("since=1h ago should include both entries, got %d", len(past))
}
// Since in the future → nothing.
future, err := s.ListWorkspaceActivity(ws.ID, models.ActivityListParams{
Since: time.Now().Add(1 * time.Hour),
})
if err != nil {
t.Fatalf("list with future since: %v", err)
}
if len(future) != 0 {
t.Errorf("since=1h ahead should exclude all entries, got %d", len(future))
}
}
func TestCreateActivityDebounced_DifferentUsersDontCoalesce(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// Create two real users so foreign key constraints are satisfied
userA, err := s.CreateUser(models.UserCreate{Email: "alice@test.com", Name: "Alice", Password: "pass-a"})
if err != nil {
t.Fatalf("create user A error: %v", err)
}
userB, err := s.CreateUser(models.UserCreate{Email: "bob@test.com", Name: "Bob", Password: "pass-b"})
if err != nil {
t.Fatalf("create user B error: %v", err)
}
// First update by user A
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
UserID: userA.ID,
})
if err != nil {
t.Fatalf("user A update error: %v", err)
}
// Second update by user B — should NOT coalesce
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
UserID: userB.ID,
})
if err != nil {
t.Fatalf("user B update error: %v", err)
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities) != 2 {
t.Errorf("different users should not coalesce: expected 2, got %d", len(activities))
}
}
func TestCreateActivityDebounced_DifferentDocsDontCoalesce(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc1 := createTestDoc(t, s, ws.ID, "Doc 1", "content 1")
doc2 := createTestDoc(t, s, ws.ID, "Doc 2", "content 2")
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc1.ID,
Action: "updated",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("doc1 update error: %v", err)
}
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc2.ID,
Action: "updated",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("doc2 update error: %v", err)
}
// Each doc should have its own activity
act1, _ := s.ListDocumentActivity(doc1.ID, models.ActivityListParams{Action: "updated"})
act2, _ := s.ListDocumentActivity(doc2.ID, models.ActivityListParams{Action: "updated"})
if len(act1) != 1 || len(act2) != 1 {
t.Errorf("different docs should not coalesce: doc1=%d, doc2=%d", len(act1), len(act2))
}
}
func TestCreateActivityDebounced_TimestampBumped(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// First update
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("first update error: %v", err)
}
activities1, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
ts1 := activities1[0].CreatedAt
// Pause so timestamps differ (RFC3339 has 1-second resolution)
time.Sleep(1100 * time.Millisecond)
// Second update — should bump timestamp
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("second update error: %v", err)
}
activities2, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities2) != 1 {
t.Fatalf("expected 1 activity, got %d", len(activities2))
}
ts2 := activities2[0].CreatedAt
if !ts2.After(ts1) {
t.Errorf("timestamp should be bumped: first=%v, second=%v", ts1, ts2)
}
}
func TestCreateActivityDebounced_MetadataMerge(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// Update with no changes metadata
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
Metadata: `{}`,
})
if err != nil {
t.Fatalf("first update error: %v", err)
}
// Update with changes metadata — should add changes
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
Metadata: `{"changes":"title: Old -> New"}`,
})
if err != nil {
t.Fatalf("second update error: %v", err)
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities) != 1 {
t.Fatalf("expected 1 activity, got %d", len(activities))
}
var meta map[string]interface{}
json.Unmarshal([]byte(activities[0].Metadata), &meta)
changes, _ := meta["changes"].(string)
if changes != "title: Old -> New" {
t.Errorf("expected changes from second update, got %q", changes)
}
}
func TestCreateActivityDebounced_AgentMetaPreserved(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// First update with agent metadata
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "agent",
Source: "cli",
Metadata: `{"agent":"claude","changes":"status: open -> active"}`,
})
if err != nil {
t.Fatalf("first update error: %v", err)
}
// Second update with agent metadata
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "agent",
Source: "cli",
Metadata: `{"agent":"claude","changes":"priority: low -> high"}`,
})
if err != nil {
t.Fatalf("second update error: %v", err)
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities) != 1 {
t.Fatalf("expected 1 activity, got %d", len(activities))
}
var meta map[string]interface{}
json.Unmarshal([]byte(activities[0].Metadata), &meta)
if meta["agent"] != "claude" {
t.Errorf("agent metadata lost: %v", meta)
}
changes, _ := meta["changes"].(string)
if changes != "status: open -> active; priority: low -> high" {
t.Errorf("expected merged changes, got %q", changes)
}
}
func TestCreateActivityDebounced_MultipleRapidSaves(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// Simulate 10 rapid autosaves
for i := 0; i < 10; i++ {
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
})
if err != nil {
t.Fatalf("save %d error: %v", i, err)
}
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities) != 1 {
t.Errorf("10 rapid saves should coalesce to 1 activity, got %d", len(activities))
}
}
func TestCreateActivityDebounced_NoUserID(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
// Two updates with no user ID (pre-auth mode)
_, err := s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
UserID: "",
})
if err != nil {
t.Fatalf("first update error: %v", err)
}
_, err = s.CreateActivityDebounced(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "user",
Source: "web",
UserID: "",
})
if err != nil {
t.Fatalf("second update error: %v", err)
}
activities, _ := s.ListDocumentActivity(doc.ID, models.ActivityListParams{Action: "updated"})
if len(activities) != 1 {
t.Errorf("expected 1 coalesced activity for no-user mode, got %d", len(activities))
}
}
func TestCollapseChanges(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "empty",
in: "",
want: "",
},
{
name: "single entry unchanged",
in: "status: open → fixed",
want: "status: open → fixed",
},
{
name: "different fields preserved",
in: "status: open → fixed; priority: low → high",
want: "status: open → fixed; priority: low → high",
},
{
name: "consecutive same-field run collapses to first→last",
// The BUG-1419 reproducer: a typed-out value chained per keystroke.
in: "component: → e; component: e → ed; component: ed → edi; component: edi → editor",
want: "component: → editor",
},
{
name: "net no-op (typed then backspaced) dropped",
// User typed "tip" then backspaced everything — no net change,
// no point keeping the entry at all.
in: "component: → t; component: t → ti; component: ti → tip; component: tip → ti; component: ti → t; component: t → ",
want: "",
},
{
name: "interleaved fields keep chronology (no global merge)",
// Run-based collapse only merges *adjacent* same-field entries;
// non-adjacent ones stay split so the timeline preserves the
// real edit order.
in: "component: a → b; status: open → fixed; component: b → c",
want: "component: a → b; status: open → fixed; component: b → c",
},
{
name: "leading from preserved through run",
in: "component: editor → ueditor; component: ueditor → uieditor; component: uieditor → ui/editor",
want: "component: editor → ui/editor",
},
{
name: "unparseable entries preserved verbatim (no field anchor → no collapse)",
// Defensive: malformed entries get passed through untouched so
// we never silently drop a metadata segment we don't understand.
in: "weird-no-arrow-here; status: open → fixed",
want: "weird-no-arrow-here; status: open → fixed",
},
{
name: "trailing/empty segments tolerated",
in: "; status: open → fixed; ; ",
want: "status: open → fixed",
},
{
name: "field cleared to empty (deletion to empty value)",
// diffFields renders a deletion-to-empty as "<key>: <from> → "
// with a trailing space. After segment-level TrimSpace the
// trailing space is gone, so the parser falls back to the
// "<value> →" suffix branch to recover from="value", to="".
in: "component: ui/editor → ",
want: "component: ui/editor → ",
},
{
name: "single structured-field same-display entry preserved",
// Regression for Codex round 1 [P2]: diffFields emits
// `implementation_notes: (1 note) → (1 note)` to signal that
// a same-cardinality replacement occurred (e.g. one note
// swapped for a different note). The display strings collapse
// to identical labels because formatChangeValue intentionally
// renders array-valued fields by count, not content — but the
// underlying data did change. collapseChanges must NOT drop
// this entry; only multi-segment runs that collapse to a
// from==to result are true no-ops.
in: "implementation_notes: (1 note) → (1 note)",
want: "implementation_notes: (1 note) → (1 note)",
},
{
name: "structured-field no-op preserved even when interleaved with a typed run",
// Defense in depth: a structured-field same-display entry
// must survive when surrounded by a typed-field collapse.
in: "component: → t; implementation_notes: (1 note) → (1 note); component: t → tip",
want: "component: → t; implementation_notes: (1 note) → (1 note); component: t → tip",
},
{
name: "repeated same-display structured-field entries preserved",
// Codex review round 2 [P2]: two PATCHes that both swapped a
// single note for a different single note. Both render as
// `(1 note) → (1 note)` because formatChangeValue summarises
// by count, but the underlying notes differ each time.
// hadTransition stays false through the merge (every `to`
// equals the anchor `from`), so the from==to drop rule
// doesn't fire — the entry is preserved as a single
// "something happened twice on this field" record.
in: "implementation_notes: (1 note) → (1 note); implementation_notes: (1 note) → (1 note)",
want: "implementation_notes: (1 note) → (1 note)",
},
{
name: "real swing that nets to zero IS still dropped",
// Regression cover for the hadTransition rule: a typed
// `foo → bar → foo` run still drops as a net no-op
// because intermediate `to=bar` differed from anchor
// `from=foo`. Without this, hadTransition would never
// flag a "true cancellation" outside the empty-start case.
in: "name: foo → bar; name: bar → foo",
want: "",
},
{
name: "structured count-return swing preserved (lossy summaries)",
// Codex review round 3 [P2]: 1 note A → 2 notes A+B → 1
// note B. diffFields emits both transitions; the merged
// shape looks like a foo→bar→foo cancellation, but the
// final note differs from the original. hasLossySummary
// blocks the drop — we can't recover the raw delta from
// the merged string, so we preserve the entry.
in: "implementation_notes: (1 note) → (2 notes); implementation_notes: (2 notes) → (1 note)",
want: "implementation_notes: (1 note) → (1 note)",
},
{
name: "lossy summary appearing only on one side still pins the run",
// Defensive: even if just `to` (or just `from`) is a lossy
// label — e.g. clearing a field renders as "(1 note) → " —
// the run must be preserved if it ever returns to from==to
// via a structured intermediate.
in: "implementation_notes: original_text → (1 note); implementation_notes: (1 note) → original_text",
want: "implementation_notes: original_text → original_text",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := collapseChanges(tt.in); got != tt.want {
t.Errorf("collapseChanges:\n in: %q\n got: %q\n want: %q", tt.in, got, tt.want)
}
})
}
}
// Regression for the BUG-1466 follow-up: when CreateActivityDebounced
// merges multiple PATCHes that all hit the same field, the merged
// `changes` string should collapse into a single first→last entry
// instead of a 30-step keystroke chain (as seen on BUG-1419's timeline
// pre-fix).
func TestMergeActivityMeta_CollapsesSameFieldRun(t *testing.T) {
existing := `{"changes":"component: → e; component: e → ed; component: ed → edi"}`
incoming := `{"changes":"component: edi → editor"}`
got := mergeActivityMeta(existing, incoming)
var m map[string]interface{}
if err := json.Unmarshal([]byte(got), &m); err != nil {
t.Fatalf("failed to parse result: %v", err)
}
changes, _ := m["changes"].(string)
want := "component: → editor"
if changes != want {
t.Errorf("expected collapsed run, got %q (want %q)", changes, want)
}
}
func TestMergeActivityMeta(t *testing.T) {
tests := []struct {
name string
existing string
incoming string
wantKey string
wantVal string
}{
{
name: "both have changes",
existing: `{"changes":"status: open -> active"}`,
incoming: `{"changes":"priority: low -> high"}`,
wantKey: "changes",
wantVal: "status: open -> active; priority: low -> high",
},
{
name: "only incoming has changes",
existing: `{}`,
incoming: `{"changes":"title: Old -> New"}`,
wantKey: "changes",
wantVal: "title: Old -> New",
},
{
name: "only existing has changes",
existing: `{"changes":"status: open -> active"}`,
incoming: `{}`,
wantKey: "changes",
wantVal: "status: open -> active",
},
{
name: "neither has changes",
existing: `{}`,
incoming: `{}`,
wantKey: "",
wantVal: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeActivityMeta(tt.existing, tt.incoming)
var m map[string]interface{}
if err := json.Unmarshal([]byte(result), &m); err != nil {
t.Fatalf("failed to parse result: %v", err)
}
got, _ := m[tt.wantKey].(string)
if tt.wantKey != "" && got != tt.wantVal {
t.Errorf("expected %q=%q, got %q", tt.wantKey, tt.wantVal, got)
}
if tt.wantKey == "" {
if _, exists := m["changes"]; exists {
t.Errorf("expected no changes key, but found one: %v", m)
}
}
})
}
}