Files
pad/internal/server/push_message_collapse_test.go
xarmian e03ba45b5c feat(web): push-to-agent composer in the item view (TASK-2561) (#1099)
* feat(web): push-to-agent composer in the item view (TASK-2561)

PLAN-2558 S3 — the web half of IDEA-2544's push-to-harness. Adds
`api.items.push`, a new `api.sessions.list`, and a "Push to agent…" row
in the item pane's ⋯ menu that opens a small composer.

The deliverable is the presence line, not the textarea. `pad push` is
fire-and-forget — no durable inbox, no ack, no "nobody was listening"
warning — which is defensible for a CLI verb typed by someone who knows
their own session is running, and indefensible for a button. So the
dialog answers "is anything listening?" before the click, and keeps
three states apart rather than two:

  N > 0        send, worded "N session connected", never "will be
               delivered" — the registry can name a session that died up
               to ~30s ago and no push gets a receipt
  N == 0       send DISABLED. Nothing listening means the message is
               lost, not queued; the empty state offers the clipboard
               instead (the fallback S4 rules for quick actions)
  can't tell   send ENABLED, uncertainty stated. A 503/401/network
               failure is not zero — rendering it as zero is the exact
               lie handleListSessions returns 503 rather than an empty
               list to avoid

The menu row is gated on a resolved user, not on canEdit: push is
self-addressed, so a viewer pushing an item into their own session is a
read. Without a user the endpoint 401s.

$lib/push/message mirrors the server's rune-after-collapse accounting so
an over-length message is caught in the composer instead of coming back
as a 400. It deliberately does not use JS `\s`: Go's unicode.IsSpace and
`\s` disagree in both directions (U+0085 is whitespace to Go only,
U+FEFF to JS only), so a `\s` client under-counts a pasted BOM and
over-counts a pasted NEL. The agreement is pinned by a shared fixture
(internal/server/testdata/push_message_cases.json) read by BOTH
internal/server/push_message_collapse_test.go and the web unit test — a
TS-only table would assert a belief about Go rather than Go's behaviour.

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

* fix(web): close the push composer's races and ambiguity gaps (codex review)

Round-1 review findings on the S3 composer, all real:

- ItemDetail did not reset `pushDialogOpen` on an item switch. The dialog
  is {#key itemSlug}-remounted while `open` is owned by the parent, so a
  stale `true` silently REOPENED the composer pointed at the new item.
  The reset block's existing comment (written for copyDialogOpen)
  describes this exact failure. Verified live, with the counterfactual:
  reverting the one-line fix reopens the dialog on item B after a
  client-side navigation. (The typed draft does NOT carry over — the
  {#key} remount clears it — so the defect is the silent reopen, not a
  retargeted message.)

- Presence polls shared one generation counter, which fences OPENINGS,
  not requests. A stalled poll could resolve after a later one and
  overwrite a fresh count with a stale one, re-arming Push against a
  session list already known to be empty. Added a per-request sequence;
  only a strictly newer response is applied.

- Nothing bounded a `/sessions` read, and 'checking' disables Push, so a
  request that never settled stranded the composer with a dead button and
  no explanation. It now degrades to the honest "can't tell" state after
  5s; a later response still lands and upgrades the answer.

- A failed send re-armed Push unconditionally. The handler publishes
  BEFORE writing its response, so an unstructured failure (rejected
  fetch, non-JSON 502) leaves the outcome genuinely unknown and a second
  click can deliver the instruction twice on an endpoint with no
  idempotency key. Split on the same line CopyItemDialog draws (DR-13):
  a structured PadApiError means the server refused before publishing —
  re-arm; anything else latches an outcome-unknown state.

- `willCollapse` compared against `String.trim()`, reintroducing the very
  JS-vs-Go whitespace mismatch $lib/push/message exists to avoid (JS
  trims a leading U+FEFF the server keeps; it leaves a U+0085 the server
  strips). Added `trimPushMessage`, which trims with Go's class.

- The textarea described only the counter, so the collapse note and the
  over-length error reached no screen reader. Both now live in one stable
  referenced node that swaps text rather than mounting and unmounting —
  an aria-describedby pointing at an absent id resolves to nothing.

- Positive presence wording implied the count was current. It now says
  "as of the last check" and names the ~30s window.

Test changes: the Go fixture test duplicated `strings.Fields` rather than
invoking the handler, so a change to the handler's normalization would
have left BOTH suites green — demonstrated by mutating the join
separator, which the copied-expression test did not notice and the new
handler-driven test caught on 22 cases. The bound is likewise now
asserted through the endpoint at 4096/4097 instead of comparing the
constant to a copy of itself.

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

* fix(web): fence the push composer against destroyed instances, unrecognised errors, and a frozen count (codex round 2)

Three findings, one of them introduced by round 1's own fix:

- The send/copy continuation fence used the generation counter, which
  cannot see a keyed REMOUNT. `{#key itemSlug}` gives item B a fresh
  instance with its own counter, so item A's in-flight send still saw its
  own `gen` unchanged and called the SHARED parent `onclose` — closing the
  composer the user had just opened for B. Added a per-instance
  `destroyed` flag, which is what actually distinguishes "still mine to
  close" from "I no longer exist".

- The outcome-unknown split treated any PadApiError as proof the server
  refused before publishing. It isn't: the API client turns EVERY JSON
  error envelope into one, including a gateway 5xx invented after the
  handler published. Replaced with a whitelist of codes the handler and
  its middleware actually emit pre-publish; everything unrecognised is
  now ambiguous. The asymmetry is deliberate — an unnecessary "we can't
  tell" costs the user a check, a wrong re-arm delivers twice.

- PRESENCE_STALL_MS only rescued the FIRST read. A later poll that hung
  froze the count at its last value indefinitely while the UI kept
  rendering "1 session connected" as fact. A known answer now expires to
  "can't tell" after 30s without a refresh — the server's own presence
  staleness bound, so past it our answer carries no more authority.

Also dropped the status→alert role swap on the composer's live region:
changing a live region's role and its text together is not reliably
honoured, so the escalation was a promise the markup couldn't keep. The
blocking condition rides `aria-invalid` on the textarea instead.

The "latest ARRIVED, not latest ISSUED" behaviour of the sequence fence
is kept and now documented as a choice: dropping an early-arriving
response because a newer request exists strands the UI when that newer
request is the one that never settles.

Each fix mutation-tested 1:1 against its new test.

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

* fix(web): complete the pre-publish whitelist and retire in-flight polls on expiry (codex round 3)

Two of round 3's three findings were real:

- `csrf_error` and `email_not_verified` are middleware refusals, written
  strictly before the handler runs, so they belong in
  PRE_PUBLISH_ERROR_CODES. Without them a CSRF mismatch told the user we
  couldn't tell whether their message was sent, when nothing had been.

- The 30s staleness expiry didn't fence requests already in flight. A
  poll issued before the expiry could land after it and reinstate the
  very count we had just declared too old to trust. Expiry now advances
  `presenceAppliedSeq` to the current `presenceSeq`, retiring those
  responses; the poll issued in the same tick carries a newer seq and
  still applies.

The third finding — that `archived` belongs in the whitelist, and that
the launcher should be hidden for archived items because "the endpoint
always rejects them" — is REFUTED. handlePushToItem has no archived gate
(`requireItemVisible` admits archived items), and pushing to an archived
item against a running server returns 200 with `pushed: true`. There is
no `archived` error code on this path to whitelist, and hiding the
launcher would remove a capability that works. Recorded rather than
silently skipped so the next reader doesn't re-derive it.

Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
2026-08-15 08:22:09 -04:00

147 lines
5.8 KiB
Go

package server
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
type pushCollapseCase struct {
Name string `json:"name"`
Raw string `json:"raw"`
Collapsed string `json:"collapsed"`
Runes int `json:"runes"`
}
func loadPushCollapseCases(t *testing.T) []pushCollapseCase {
t.Helper()
var fixture struct {
Cases []pushCollapseCase `json:"cases"`
}
raw, err := os.ReadFile(filepath.Join("testdata", "push_message_cases.json"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatalf("parse fixture: %v", err)
}
// Guard against a fixture that silently empties out (a bad merge, a
// rewritten generator): an empty table would make these tests pass while
// proving nothing, which is the exact false-green shape CONVE-12 names.
if len(fixture.Cases) < 20 {
t.Fatalf("fixture has %d cases, expected the full table — did it get truncated?", len(fixture.Cases))
}
return fixture.Cases
}
// This file is one half of a cross-language contract test (PLAN-2558 S3).
//
// handlePushToItem measures a push message in runes AFTER whitespace collapse
// and REJECTS an over-length one with a 400 rather than truncating it. The web
// composer has to apply the same accounting to warn the user before they
// submit — but "the same" is a claim about Go's unicode.IsSpace vs
// JavaScript's \s, and those two genuinely differ (U+0085 is whitespace to Go
// only; U+FEFF to JS only). A TypeScript-only test would assert the author's
// BELIEF about Go, not Go's behaviour.
//
// So both suites read one fixture. This test pins it to what the server
// actually does; web/src/lib/push/message.test.ts pins the client to the same
// table. If either implementation drifts, exactly one of the two goes red and
// names the case.
func TestPushMessageCollapseFixture(t *testing.T) {
for _, tc := range loadPushCollapseCases(t) {
t.Run(tc.Name, func(t *testing.T) {
// The exact expression handlePushToItem applies to input.Message.
got := strings.Join(strings.Fields(tc.Raw), " ")
if got != tc.Collapsed {
t.Errorf("collapse(%q) = %q, fixture says %q", tc.Raw, got, tc.Collapsed)
}
// The exact expression its length check applies to the result.
if n := len([]rune(got)); n != tc.Runes {
t.Errorf("len([]rune(collapse(%q))) = %d, fixture says %d", tc.Raw, n, tc.Runes)
}
})
}
}
// The test above asserts the EXPRESSION handlePushToItem uses, copied into the
// test — which cannot notice the handler switching to a different
// normalization (codex review). This one drives the real endpoint for every
// fixture case and reads the collapsed form back off the response, so the
// table is pinned to the code path a client actually talks to.
//
// The two are kept separate rather than merged: this one needs a server, a
// user and a workspace per case, while the expression-level test is instant
// and pinpoints WHICH of collapse-vs-count disagrees when one does.
func TestPushMessageCollapseThroughTheHandler(t *testing.T) {
t.Parallel()
srv := testServerWithWatchEvents(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
path := "/api/v1/workspaces/" + slug + "/items/" + item.Slug + "/push"
for _, tc := range loadPushCollapseCases(t) {
t.Run(tc.Name, func(t *testing.T) {
rr := bearerJSON(t, srv, "POST", path, tok.Token,
map[string]interface{}{"message": tc.Raw})
// A case that collapses to nothing is the handler's own 400
// condition — and the 400 IS the assertion that the collapse
// emptied it, so it is evidence rather than an exempted case.
if tc.Collapsed == "" {
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for a message that collapses to empty, got %d (%s)", rr.Code, rr.Body.String())
}
return
}
if rr.Code != http.StatusOK {
t.Fatalf("push: %d %s", rr.Code, rr.Body.String())
}
var resp pushResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
// pushResponse.Message echoes the post-collapse text — the exact
// bytes that went onto the bus as Notification.Summary.
if resp.Message != tc.Collapsed {
t.Errorf("handler collapsed %q to %q, fixture says %q", tc.Raw, resp.Message, tc.Collapsed)
}
if n := len([]rune(resp.Message)); n != tc.Runes {
t.Errorf("handler produced %d runes for %q, fixture says %d", n, tc.Raw, tc.Runes)
}
})
}
}
// The fixture's whitespace cases are only meaningful if they agree with the
// bound the handler actually enforces. Asserted THROUGH the endpoint at the
// boundary — 4096 collapsed runes accepted, 4097 refused — rather than by
// comparing the constant to a copy of itself, so the web composer's
// PUSH_MESSAGE_MAX_LEN is pinned to observed behaviour.
func TestPushMessageBoundIsWhatTheWebComposerMirrors(t *testing.T) {
t.Parallel()
// The number hard-coded in web/src/lib/push/message.ts.
const mirroredInWebClient = 4096
srv := testServerWithWatchEvents(t)
slug, item, tok, _ := setupWatchTestUser(t, srv)
path := "/api/v1/workspaces/" + slug + "/items/" + item.Slug + "/push"
atLimit := strings.Repeat("a", mirroredInWebClient)
if rr := bearerJSON(t, srv, "POST", path, tok.Token,
map[string]interface{}{"message": atLimit}); rr.Code != http.StatusOK {
t.Fatalf("a %d-rune message was refused (%d %s) — the server bounds lower than the web composer, which will refuse text users are entitled to send",
mirroredInWebClient, rr.Code, rr.Body.String())
}
overLimit := strings.Repeat("a", mirroredInWebClient+1)
if rr := bearerJSON(t, srv, "POST", path, tok.Token,
map[string]interface{}{"message": overLimit}); rr.Code != http.StatusBadRequest {
t.Fatalf("a %d-rune message was accepted (%d) — the server bounds higher than the web composer, which will block text the server would take",
mirroredInWebClient+1, rr.Code)
}
}