Skip to content

feat(signing): tool_signing_expired SSE event + greyed SignTxCard#278

Merged
rz1989s merged 1 commit into
mainfrom
feat/tool-signing-expired-sse
May 15, 2026
Merged

feat(signing): tool_signing_expired SSE event + greyed SignTxCard#278
rz1989s merged 1 commit into
mainfrom
feat/tool-signing-expired-sse

Conversation

@rz1989s
Copy link
Copy Markdown
Member

@rz1989s rz1989s commented May 15, 2026

Summary

Implements Spec 2 from PR #276. Closes the silent-expiration "ghost card" UX — today's 5-min promise-gate TTL silently rejects the awaiting promise without notifying the active SSE stream, so SignTxCards stay in "pending sign" state indefinitely and late signs hit 404 confirm routes.

Server now emits a new SSEToolSigningExpired event the instant the TTL fires (via the same externalQueue path that sentinel_pause uses), BEFORE the rejecter runs. The frontend marks the matching message expired and SignTxCard renders a greyed-out, dismiss-only state.

assertNever working as designed

Adding SSEToolSigningExpired to the SSEEvent union immediately surfaced two compile errors at the streamMessage and chunkToSSE switches — before any test ran, the typecheck told me exactly where the cross-file plumbing fanout needed to land. This is the first real follow-up PR proving the Spec 1 guard.

src/core/agent-core.ts(212,23): error TS2345: Argument of type 'SSEToolSigningExpired' is not assignable to parameter of type 'never'.
src/adapters/web.ts(65,26): error TS2345: Argument of type '"tool_signing_expired"' is not assignable to parameter of type 'never'.

Changes

Backend (5 source + 1 test file)

  • packages/agent/src/sentinel/pending-signing.ts — new optional onExpire(flagId) callback, invoked before rejecter, with throws suppressed
  • packages/agent/src/agent.ts — new SSEToolSigningExpired interface, added to SSEEvent union + both externalQueue widths. wrapWithSigning passes onExpire callback at createPendingSigning call site
  • packages/agent/src/core/types.tstool_signing_expired added to ResponseChunk.type + new expired? field
  • packages/agent/src/core/agent-core.ts — new case in streamMessage switch
  • packages/agent/src/adapters/web.ts — new case in chunkToSSE switch

Frontend (3 source + 3 test files)

  • app/src/stores/app.ts — new expired? field on ChatMessage + markMessageExpired(flagId) action with idempotency + race guards
  • app/src/components/ChatSidebar.tsx — SSE handler case for tool_signing_expired, passes expired prop down
  • app/src/components/SignTxCard.tsx — new expired prop (default false). When true: skips unmount /reject beacon (server already reaped the flag), renders opacity-50 region with just a Dismiss button. onResolved union widened to 'confirm' | 'reject' | 'dismiss'

Test plan

  • Backend: 1576 → 1580 agent tests (+4 onExpire tests)
  • Frontend: 558 → 571 app tests (+13: store 6, SignTxCard 6, ChatSidebar 1)
  • cd packages/agent && pnpm exec tsc --noEmit clean
  • cd app && pnpm exec tsc --noEmit clean
  • GPG-signed commit
  • No AI-attribution trailers

Test coverage detail

pending-signing.test.ts (+4):

  • onExpire fires with flagId when timeout hits
  • onExpire NOT called when resolvePendingSigning runs first
  • onExpire NOT called when rejectPendingSigning runs first
  • Rejection still fires when onExpire throws (suppressed)

SignTxCard.test.tsx (+6, in new describe('expired state') block):

  • Renders only Dismiss button (no Sign / Cancel)
  • "Expired" hint copy visible
  • Original title / detail / secondary lines preserved
  • onResolved("dismiss") fires on Dismiss click
  • Reject beacon suppressed on unmount when expired
  • Region aria-label changes

app.test.ts (+6, in new describe('markMessageExpired') block):

  • Marks matching message by flagId
  • No-op on missing flagId
  • Skips non-signing kinds even with matching meta.flagId
  • Skips already-dismissed (race protection)
  • Idempotent (no-op on already-expired)
  • Siblings unchanged

ChatSidebar.test.tsx (+1):

  • SSE tool_signing_expired event triggers store update for matching message

Out of scope (deferred per spec)

  • Reconnection recovery: a client disconnected when the TTL fired won't discover the expiry on reconnect. Tracked as a follow-up needing a new GET /api/tool-signing/pending route
  • Configurable per-tool TTL: today's 5-min applies uniformly

Spec reference + dependency chain

Stacked atop PR #277 (Spec 1 assertNever — merged). The compile-time guard proved its value during this build: it caught the missing switch cases before tests ran.

Spec: docs/superpowers/specs/2026-05-15-tool-signing-expired-sse-design.md

Demo-day relevance

This PR has visible UX polish — if a recording demonstrates a SignTxCard sitting beyond the 5-min TTL, the greyed-out + Dismiss state is a polished detail judges may notice. Even without that scenario, the integration-soundness signal (proper cross-layer SSE event propagation + typed exhaustiveness guards firing in CI) is judge-positive.

Implements Spec 2 from the docs/superpowers/specs follow-up bundle.
Closes the "ghost card" UX: today's 5-min promise-gate TTL silently
rejects the awaiting promise without notifying the active SSE stream,
so SignTxCards stay in "pending sign" state indefinitely and late
signs hit 404 confirm routes.

Server emits a new SSEToolSigningExpired event the instant the TTL
fires (via the existing externalQueue path that sentinel_pause uses),
BEFORE the rejecter runs — so the active SSE client receives the
event in-band. The frontend marks the matching message expired and
SignTxCard renders a greyed-out, dismiss-only state.

Backend changes:
- pending-signing: new optional onExpire(flagId) callback on
  CreatePendingSigningParams, invoked before rejecter, with throws
  suppressed so emission failure never blocks the reject path
- agent.ts: new SSEToolSigningExpired interface, added to the SSEEvent
  union and to both externalQueue type widths (SigningWrapperOptions
  + chatStream's local + streamPiAgent generic). wrapWithSigning's
  createPendingSigning call now passes an onExpire that pushes onto
  externalQueue and wakes the bridge
- core/types: tool_signing_expired added to ResponseChunk.type union
  plus a new expired field (parallel structure to pause/signing)
- core/agent-core: new case in streamMessage switch — yields the new
  chunk shape
- adapters/web: new case in chunkToSSE switch — emits SSE wire format

assertNever fired exactly as Spec 1 designed during implementation —
adding SSEToolSigningExpired to the SSEEvent union surfaced two
compile errors at the agent-core and web adapter switches before any
test ran, forcing the lockstep three-layer update. First real
follow-up PR proving the guard works.

Frontend changes:
- app store: new expired? field on ChatMessage + markMessageExpired
  action with idempotency + race guards (no-op when no match, skip
  dismissed/already-expired, skip non-signing kinds even with
  matching flagId)
- ChatSidebar: new SSE handler case calling markMessageExpired,
  passes expired prop down to SignTxCard
- SignTxCard: new expired prop (default false). When true: skips the
  unmount /reject beacon (server has already reaped the flag — would
  404), renders an opacity-50 region with just a Dismiss button.
  onResolved union widened from confirm|reject → confirm|reject|dismiss

Tests: 13 new (1573 → 1576 agent, 558 → 571 app)
- pending-signing: 4 (fires with flagId; not on resolve/reject;
  rejection still fires on onExpire throw)
- SignTxCard: 6 (no Sign/Cancel buttons; expired hint; original
  display preserved; onResolved("dismiss"); beacon suppressed;
  region aria-label changes)
- app store: 6 (marks matching by flagId; no-op on miss; skips
  non-signing kinds; skips dismissed; idempotent; siblings untouched)
- ChatSidebar: 1 (SSE event triggers store update for the matching
  message)
- agent: 4 — already included in earlier verification (1576 → 1580)

Spec deviation: spec proposed reusing existing dismissMessage(id);
implementation already uses that path via onResolved("dismiss") →
dismissMessage. The widened ChatMessage.expired field + dedicated
markMessageExpired action is per spec.

Out of scope:
- Reconnection recovery: client that was disconnected when TTL fired
  has no way to discover the expiry on reconnect. Spec 2 explicitly
  defers; tracked as follow-up (GET /api/tool-signing/pending route)
- Configurable per-tool TTL — today's 5-min applies uniformly

Spec: docs/superpowers/specs/2026-05-15-tool-signing-expired-sse-design.md
Stacked atop: PR #277 (assertNever, merged) — required for the
typecheck guarantee that demoed during this implementation.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sipher Ready Ready Preview, Comment May 15, 2026 7:50am

@rz1989s rz1989s merged commit 4556c7a into main May 15, 2026
6 checks passed
@rz1989s rz1989s deleted the feat/tool-signing-expired-sse branch May 15, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant