Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions internal/cloud/cloudstore/dashboard_queries_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1594,3 +1594,131 @@ func TestPromptDetailNotFoundReturnsPromptNotFoundError(t *testing.T) {
t.Errorf("must NOT be ErrDashboardProjectNotFound for missing prompt in valid project")
}
}

// TestDashboardCountsPiSavedPromptUnderItsProject closes the last gap #706 named: the reporter's
// prompt "saved" locally while the cloud dashboard kept showing 0 prompts for the project.
//
// It seeds the read model with a prompt delivered the way a locally saved prompt actually reaches
// the cloud β€” an upsert mutation keyed by sync_id β€” and asserts the dashboard surfaces that
// prompt under its own project: the project prompt count, the recent-prompts list, and the detail
// page the sync_id addresses. A neighbouring project must not see it.
func TestDashboardCountsPiSavedPromptUnderItsProject(t *testing.T) {
const (
targetProject = "paidosdep"
otherProject = "skill-registry"
promptSyncID = "prompt-paidosdep-1"
promptSession = "manual-save-paidosdep"
promptContent = "preserve this exact user prompt about auth token rotation"
)

// Mirrors the payload the local store enqueues for a prompt upsert.
mutationPayload, err := json.Marshal(map[string]any{
"sync_id": promptSyncID,
"session_id": promptSession,
"content": promptContent,
"project": targetProject,
"created_at": "2026-04-23T08:20:00Z",
})
if err != nil {
t.Fatalf("marshal mutation payload: %v", err)
}

targetChunk, err := json.Marshal(map[string]any{
"sessions": []map[string]any{
{"id": promptSession, "project": targetProject, "started_at": "2026-04-23T08:00:00Z"},
},
"mutations": []map[string]any{
{"entity": "prompt", "entity_key": promptSyncID, "op": "upsert", "payload": string(mutationPayload)},
},
})
if err != nil {
t.Fatalf("marshal target chunk: %v", err)
}

chunks := []dashboardChunkRow{
{
chunkID: "chunk-paidosdep-1", project: targetProject, createdBy: "alice",
createdAt: time.Date(2026, 4, 23, 10, 0, 0, 0, time.UTC),
parsed: parseMustChunk(t, targetChunk),
},
{
chunkID: "chunk-skill-registry-1", project: otherProject, createdBy: "alice",
createdAt: time.Date(2026, 4, 23, 11, 0, 0, 0, time.UTC),
parsed: parseMustChunk(t, []byte(`{
"sessions":[{"id":"manual-save-skill-registry","project":"skill-registry","started_at":"2026-04-23T09:00:00Z"}],
"prompts":[{"sync_id":"prompt-skill-registry-1","session_id":"manual-save-skill-registry","project":"skill-registry","content":"an unrelated prompt","created_at":"2026-04-23T09:20:00Z"}]
}`)),
},
}

model, err := buildDashboardReadModel(chunks)
if err != nil {
t.Fatalf("buildDashboardReadModel: %v", err)
}
cs := &CloudStore{
dashboardReadModelLoad: func() (dashboardReadModel, error) { return model, nil },
}

// The symptom in #706: the project row kept reporting 0 prompts.
projects, err := cs.ListProjects("")
if err != nil {
t.Fatalf("ListProjects: %v", err)
}
var targetRow *DashboardProjectRow
for i := range projects {
if projects[i].Project == targetProject {
targetRow = &projects[i]
break
}
}
if targetRow == nil {
t.Fatalf("project %q missing from the dashboard project list", targetProject)
}
if targetRow.Prompts != 1 {
t.Fatalf("expected 1 prompt on the %q dashboard row, got %d", targetProject, targetRow.Prompts)
}

prompts, err := cs.ListRecentPrompts(targetProject, "", 10)
if err != nil {
t.Fatalf("ListRecentPrompts: %v", err)
}
var listed *DashboardPromptRow
for i := range prompts {
if prompts[i].SyncID == promptSyncID {
listed = &prompts[i]
break
}
}
if listed == nil {
t.Fatalf("prompt %q not listed on the %q dashboard (got %d prompts)", promptSyncID, targetProject, len(prompts))
}
if listed.Content != promptContent {
t.Fatalf("dashboard prompt content changed: %q", listed.Content)
}
if listed.SessionID != promptSession {
t.Fatalf("expected dashboard prompt session %q, got %q", promptSession, listed.SessionID)
}
if listed.Project != targetProject {
t.Fatalf("expected dashboard prompt project %q, got %q", targetProject, listed.Project)
}

// The detail page is addressed by sync_id, so that identity must resolve.
detail, _, _, err := cs.GetPromptDetail(targetProject, promptSession, promptSyncID)
if err != nil {
t.Fatalf("GetPromptDetail: %v", err)
}
if detail.SyncID != promptSyncID || detail.Content != promptContent {
t.Fatalf("prompt detail does not describe the saved prompt: %+v", detail)
}

// Scope: the neighbouring project must not show this prompt.
otherPrompts, err := cs.ListRecentPrompts(otherProject, "", 10)
if err != nil {
t.Fatalf("ListRecentPrompts other project: %v", err)
}
for _, p := range otherPrompts {
if p.SyncID == promptSyncID {
t.Fatalf("prompt %q leaked onto the %q dashboard", promptSyncID, otherProject)
}
}
}
155 changes: 155 additions & 0 deletions internal/server/server_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1046,3 +1046,158 @@ func TestStoreClosedExtraServerBranchesE2E(t *testing.T) {
}
exportResp.Body.Close()
}

// TestPiPromptPersistenceE2E replays the exact wire sequence the Pi plugin's mem_save_prompt
// issues (POST /sessions, then POST /prompts) and proves the prompt is durably persisted,
// retrievable, and scoped to its project.
//
// Regression for #706: the reported symptom was a "saved" response carrying an id that resolved
// to an unrelated entry from another project. The id was never stale β€” prompts are numbered from
// user_prompts, a sequence independent of observations β€” so the response id must read back as the
// prompt that was just saved, and must not resolve as an observation.
func TestPiPromptPersistenceE2E(t *testing.T) {
_, ts := newE2EServer(t)
client := ts.Client()

const (
targetProject = "paidosdep"
otherProject = "skill-registry"
promptContent = "preserve this exact user prompt about auth token rotation"
)

// The plugin derives a stable per-project session id when the caller passes an explicit
// project, and creates that session before writing the prompt.
targetSession := "manual-save-" + targetProject
otherSession := "manual-save-" + otherProject

for _, s := range []struct{ id, project string }{
{targetSession, targetProject},
{otherSession, otherProject},
} {
sessionResp := postJSON(t, client, ts.URL+"/sessions", map[string]any{
"id": s.id,
"project": s.project,
"directory": "/tmp/" + s.project,
})
if sessionResp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201 creating session %q, got %d", s.id, sessionResp.StatusCode)
}
sessionResp.Body.Close()
}

// An observation in the other project gives both id sequences live rows, so the namespace
// assertions below exercise the collision #706 actually hit rather than an empty table.
obsResp := postJSON(t, client, ts.URL+"/observations", map[string]any{
"session_id": otherSession,
"title": "unrelated entry",
"content": "an observation that must never answer for a prompt id",
"type": "manual",
"project": otherProject,
"scope": "project",
})
if obsResp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201 creating observation, got %d", obsResp.StatusCode)
}
obsResp.Body.Close()

promptResp := postJSON(t, client, ts.URL+"/prompts", map[string]any{
"session_id": targetSession,
"content": promptContent,
"project": targetProject,
})
if promptResp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201 creating prompt, got %d", promptResp.StatusCode)
}
created := decodeJSON[map[string]any](t, promptResp)
if created["status"] != "saved" {
t.Fatalf("expected saved status, got %v", created["status"])
}
promptID, ok := created["id"].(float64)
if !ok || promptID <= 0 {
t.Fatalf("expected a positive prompt id, got %v", created["id"])
}

// The prompt is retrievable under its own project, and the returned id resolves to the
// content that was just written β€” not to some pre-existing row.
recentResp, err := client.Get(ts.URL + "/prompts/recent?project=" + targetProject)
if err != nil {
t.Fatalf("recent prompts: %v", err)
}
if recentResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 recent prompts, got %d", recentResp.StatusCode)
}
recent := decodeJSON[[]store.Prompt](t, recentResp)
var saved *store.Prompt
for i := range recent {
if recent[i].ID == int64(promptID) {
saved = &recent[i]
break
}
}
if saved == nil {
t.Fatalf("prompt id %d not retrievable from /prompts/recent for project %q (got %d prompts)", int64(promptID), targetProject, len(recent))
}
if saved.Content != promptContent {
t.Fatalf("prompt id %d resolved to unexpected content %q", int64(promptID), saved.Content)
}
if saved.Project != targetProject {
t.Fatalf("expected prompt project %q, got %q", targetProject, saved.Project)
}
if saved.SessionID != targetSession {
t.Fatalf("expected prompt session %q, got %q", targetSession, saved.SessionID)
}
// A sync_id is what carries this prompt to the cloud dashboard; without it the row is local-only.
if strings.TrimSpace(saved.SyncID) == "" {
t.Fatalf("expected prompt %d to carry a sync_id for cloud replication", int64(promptID))
}

// Project scoping: another project must not see this prompt.
otherResp, err := client.Get(ts.URL + "/prompts/recent?project=" + otherProject)
if err != nil {
t.Fatalf("recent prompts other project: %v", err)
}
if otherResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 recent prompts for other project, got %d", otherResp.StatusCode)
}
for _, p := range decodeJSON[[]store.Prompt](t, otherResp) {
if p.ID == int64(promptID) {
t.Fatalf("prompt %d leaked into project %q", int64(promptID), otherProject)
}
}

// Search is the other retrieval surface the dashboard and agents use.
searchResp, err := client.Get(ts.URL + "/prompts/search?q=rotation&project=" + targetProject + "&limit=5")
if err != nil {
t.Fatalf("search prompts: %v", err)
}
if searchResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 searching prompts, got %d", searchResp.StatusCode)
}
found := false
for _, p := range decodeJSON[[]store.Prompt](t, searchResp) {
if p.ID == int64(promptID) {
found = true
break
}
}
if !found {
t.Fatalf("prompt %d not found via /prompts/search", int64(promptID))
}

// The disambiguation #706 asked for: the prompt id is not an observation id. Reading it as one
// must never answer with this prompt's content.
obsLookup, err := client.Get(ts.URL + "/observations/" + strconv.FormatInt(int64(promptID), 10))
if err != nil {
t.Fatalf("observation lookup: %v", err)
}
defer obsLookup.Body.Close()
if obsLookup.StatusCode == http.StatusOK {
raw, err := io.ReadAll(obsLookup.Body)
if err != nil {
t.Fatalf("read observation lookup: %v", err)
}
if strings.Contains(string(raw), promptContent) {
t.Fatalf("prompt id %d resolved to an observation carrying the prompt content", int64(promptID))
}
}
}
Loading