From 59a55dc58d35310e4d209c64bc913980993f1627 Mon Sep 17 00:00:00 2001 From: equationalapplications Date: Wed, 1 Jul 2026 22:06:09 -0400 Subject: [PATCH 1/8] docs(spec): add credit economy repricing design (July 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstormed spec for the finalized per-action credit pricing table — metered live-voice/agent-loop billing, new embedding/summarize costs, and the multi-row FIFO port needed in cloud-agent to support >1-credit spends. --- ...6-07-01-credit-economy-repricing-design.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md diff --git a/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md b/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md new file mode 100644 index 00000000..56edd020 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md @@ -0,0 +1,228 @@ +# Credit Economy Repricing (July 2026) — Design Spec + +**Date:** 2026-07-01 +**Status:** Draft +**Supersedes (partially):** `2026-07-01-credit-improvements-design.md` (PR #506) documented cloud-agent +`spendCredit` as "not modified — it only ever spends 1, which cannot fragment." This spec changes +that: live voice moves from 1 to 5 credits/tick, which *can* span multiple `credit_transactions` +rows, so cloud-agent needs the same multi-row FIFO allocator functions/ already has. +**Interacts with:** `2026-07-01-live-voice-credit-reconciliation-design.md` (PR pending) assumed the +live-voice connect gate stays `≥ 2` forever ("No change to the live-voice billing backend or the +≥ 2 connect gate"). That assumption is now stale — this spec raises the gate to `≥ 5`. The +reconciliation spec's actual mechanism (client-side badge sync via `USAGE_SNAPSHOT_RECEIVED`) is +unaffected; only the gate constant and the "~5 min runway" UI comment context change. + +--- + +## Overview + +Finalized per-action credit pricing, replacing today's mostly-flat 1-credit-per-call model with +per-mechanism costs sized to target margins. Two actions that are currently **entirely unbilled** +(`summarizeText`, `generateEmbedding`) become billed. Live voice and the cloud-agent tool loop move +from flat-per-call billing to metered billing (per tick / per internal loop iteration). + +| Action | Path | Old cost | New cost | Target margin | +|---|---|---|---|---| +| Live Voice | cloud-agent `/agent/live` (wsLiveAgentHandler) | 1 / 60s tick | **5 / 60s tick** | ~74% | +| Agent Turn | cloud-agent `POST /agent/run` | 1 / turn (flat) | **1 / internal loop, max 5** | ~61%+ | +| Doc Conversion | `convertDocumentText` | 1 | **2** | ~74% | +| Image Gen | `generateImage` | 1 | **2** | ~77% | +| Embeddings | `generateEmbedding` | unbilled | **1 / 50k chars** (`Math.ceil`) | ~98% | +| Summarization | `summarizeText` | unbilled | **1 / call** | Variable (high) | +| Text Chat (Grounded) | `generateReply`, no explicit `tools` (default googleSearch) | 1 | **3** | ~87% | +| Text Chat (Standard) | `generateReply`, explicit `tools` passed | 1 | **1** | ~70% | +| Wiki Sync / DOCX / memory write/heal | `wikiSync`, `wikiLlm`, `memoryWrite`, `memoryHeal` | 1 | **1 (no change)** | ~99% (subsidizer) | + +**Rollout:** hard cutover. New rates apply the moment each service deploys, including any live-voice +call already connected — Cloud Run replaces the running instance on deploy, dropping the websocket +anyway, so an in-flight session surviving the deploy boundary isn't a real scenario worth +engineering around. No rate-versioning or grandfathering. + +--- + +## A) Firebase Callables (`functions/src`) + +`functions/src/services/creditService.ts` already supports variable `amount` via +`spendCredits(userId, amount): Promise` — no service-layer change +needed here, only call-site changes. + +| Function | File | Change | +|---|---|---| +| `convertDocumentText` | `convertDocumentText.ts:187` | `spendCredits(userId, 1)` → `spendCredits(userId, 2)` | +| `generateImage` | `generateImage.ts:127` | `spendCredits(userId, 1)` → `spendCredits(userId, 2)` | +| `summarizeText` | `summarizeText.ts` | **New.** Add `spendCredits(userId, 1)` before the Vertex call; `refundCredit` in the existing `catch` around `generateSummary` (handler.ts:155-163). Currently fully unbilled — no chunking exists (single call, ≤16k chars in, one summary out), so the whole call is billed as 1 unit. No block-splitting added. | +| `generateEmbedding` | `generateEmbedding.ts` | **New.** Add `spendCredits(userId, Math.ceil(text.length / 50_000))` before the Vertex call; `refundCredit` on failure. `MAX_TEXT_LENGTH` stays `8_000` (unchanged) — the formula always resolves to `1` under that cap today. Shipped as-is anyway: future-proofing for when the cap is raised (e.g. larger-context embedding model or chunking is added later), at which point multi-credit billing activates automatically with no further billing-logic change. | +| `generateReply` | `generateReply.ts:539` | Split by the existing grounded/standard branch already in the code (`generateReply.ts:300-315`, `toGenAITool`/`buildToolsForRequest`): caller passes explicit `tools` → standard → `spendCredits(userId, 1)`. Caller passes no `tools` (defaults to `googleSearchManifest`) → grounded → `spendCredits(userId, 3)`. | + +All four use the existing spend-first / refund-in-catch pattern already proven in +convertDocumentText, generateImage, and generateReply — no new error-handling shape. + +--- + +## B) Cloud-agent HTTP — `POST /agent/run` + +### Current behavior + +`cloud-agent/src/index.ts:228-331`. One flat credit spent **before** `runAgentReal` runs (line 261), +refunded whole on pre-agent failure (line 287) or ADK error (line 299). `runAgentReal` +(`index.ts:65-138`) drives the ADK loop via `runner.runAsync(...)` and a `for await (const event of +events)` loop (lines 111-132) with no app-level iteration cap — looping is entirely delegated to the +ADK library. + +### New behavior + +Move credit deduction **inside** the `for await` loop. On each iteration that yields a tool-call +(`functionCall` part detected, mirroring the existing `toolCalls.push(fc.name)` logic at line 119), +spend 1 credit via `cs.spendCredit(userId)` and increment `loopCount`. + +- **Loop cap — hard stop.** At `loopCount === 5`, stop consuming the event stream (`events.return?.()` + if the ADK async generator supports it, else `break`) instead of letting ADK continue. Force + whatever reply/summary is available at that point rather than an additional tool-call round. This + bounds both cost and latency at the app level, not just the billing. +- **Mid-loop insufficient credits.** If `cs.spendCredit(userId)` throws `INSUFFICIENT_CREDITS` + partway through a multi-tool turn (balance exhausted after N < 5 iterations), treat it the same as + hitting the loop cap: stop consuming events, return the partial reply built so far, not a 500. The + agent already did useful work; degrade gracefully rather than discarding it. +- **Removed:** the single pre-loop `spendCredit` call and its dedicated refund-on-precheck-failure + path (lines 258-269). The first loop iteration's spend now serves as the balance gate — if the user + has 0 credits, the very first `spendCredit` call throws `INSUFFICIENT_CREDITS` before any tool + executes, functionally equivalent to today's pre-flight check but co-located with the metering + logic instead of duplicated. +- **Refund scope on ADK error:** refund only the credits actually spent this turn (the accumulated + txIds from however many loop iterations completed), not a fixed "1 credit" — this is what the + Section C signature change (below) enables. + +### `browser_action` interaction (verify, don't redesign) + +`browser_action` tool calls happen inside this same ADK loop. Today, the text-path `browser_action` +handler skips its own `spendCredit` call (`preBilled: true` — see `docs/billing-and-credits.md:50`) +because the whole turn was already pre-paid with one flat credit before the loop ran. Under the new +per-loop billing, a `browser_action` invocation is just one more loop iteration and gets billed +naturally via the per-iteration `spendCredit`. The existing "skip, preBilled" logic in the +browser_action path should continue to skip its own separate spend — confirm this with a test +(`browserAction.test.ts`) so text-path browser_action isn't double-billed or silently unbilled after +this change. This is a verification item for the plan, not a design change — `browser_action`'s own +flat voice-path credit (1, contextual, unrelated timer) is out of scope for this spec entirely. + +--- + +## C) Live Voice Websocket — `wsLiveAgentHandler.ts` + +### `cloud-agent/src/services/creditService.ts` — signature change + +Current `spendCredit(userId): Promise` does a single-row atomic `UPDATE ... WHERE +remaining_balance >= 1` (lines 12-91) — it can only ever spend exactly 1 and cannot span rows. That +was an intentional simplification from PR #506 ("cannot fragment" because nothing spent more than +1). A 5-credit live-voice tick breaks that assumption: a user's balance can be fragmented across +multiple `credit_transactions` rows (e.g. 3 left in an expiring subscription grant + 2 in a signup +grant), and no single row may hold 5. + +**Fix:** port the multi-row FIFO allocator already proven in +`functions/src/services/creditService.ts:127-` (net-balance check against the requested `amount`, +then loop-allocate across rows ordered `expires_at ASC NULLS LAST` until `amount` is exhausted, all +inside one DB transaction, same lock ordering — subscriptions row first, then credit_transactions) +into `cloud-agent/src/services/creditService.ts`. + +New signature: `spendCredit(userId: string, amount = 1): Promise` (array of debited +`credit_transactions` row ids, one or more). `refundCredit(userId: string, txIds: string[]): +Promise` refunds the full set atomically in one transaction. + +**Ripple:** every existing caller of `spendCredit`/`refundCredit` in cloud-agent changes from a +single `string` txId to a `string[]`: +- `POST /agent/run` (Section B) — `amount` always 1 per call now, single-element array, refund logic + updated to pass/spread the array instead of one txId. +- `wsLiveAgentHandler.ts:339` — `cs.spendCredit(userId, 5)`, one atomic call/transaction per tick + (not five sequential 1-credit calls — avoids N+1 query thrashing on a hot 60s-recurring path). +- `wsAgentHandler.ts`, `wsBrowserAgentHandler.ts`, `schedulerTriggerHandler.ts` — signature-only + update (still `amount = 1`), test mocks updated to return/accept arrays. + +This duplicates FIFO-allocation SQL logic across two codebases (functions/ and cloud-agent/) rather +than sharing it — accepted tradeoff to keep the services decoupled and avoid an internal RPC hop on +every 60-second billing tick (network round-trip + new service-to-service auth surface would be +worse for a hot path than duplicated, well-tested SQL). + +### Gate and timer + +- Timer interval unchanged: `billingIntervalMs` defaults `60_000` (`wsLiveAgentHandler.ts:108`). +- Tick deduction: `cs.spendCredit(userId, 5)` (was `cs.spendCredit(userId)` → 1) at line 339. +- Server connect gate (`wsLiveAgentHandler.ts:316`): `balance < 2` → `balance < 5`. +- Client connect gate (`src/hooks/useLiveVoiceChat.ts:31`): `MIN_CREDITS_FOR_CALL = 2` → `5`. + +### Talk UI — `LOW_CREDIT_THRESHOLD` + +`app/(drawer)/(tabs)/talk/index.tsx`'s `LOW_CREDIT_THRESHOLD = 5` was sized as "~5 min runway at the +old 1 credit/60s rate." At the new 5 credits/60s rate, 5 credits now buys ~1 minute, not 5. +**Decision: leave `LOW_CREDIT_THRESHOLD = 5` unchanged.** The low-credit warning now fires with less +runway remaining than before — accepted, no scaling. + +--- + +## D) Documentation — `docs/billing-and-credits.md` + +Rewrite the Credit Consumption table (currently lines 28-38) with the new costs from the Overview +table above. Specific changes: +- Split the single `generateReply` row into two: grounded (3) and standard (1), keyed off + presence/absence of caller-supplied `tools`. +- Add rows for `summarizeText` (1) and `generateEmbedding` (1 per 50k chars, `Math.ceil`) — + currently absent because both are unbilled. +- Update Agent Turn row: `1 / turn (flat)` → `1 / internal loop, max 5`. +- Update Live Voice row: `1 / 60s timer` → `5 / 60s timer`. +- Update the connect-gate line (currently line 39): `≥ 2` → `≥ 5`. +- `browser_action` contextual billing section (lines 43-56) — **unchanged**, not part of the + finalized table, out of scope. +- Wiki/DOCX/memory rows — unchanged (no-op row, still 1). + +--- + +## E) Consumer-facing copy + +Two spots reference the stale "1 credit per minute" live-voice figure and need updating to 5: + +| File | Line | Current | New | +|---|---|---|---| +| `app/index.web.tsx` | 29 | "...1 credit per minute for live voice." | "...5 credits per minute for live voice." | +| `src/components/LandingPage/FeaturesSection.tsx` | 11 | "(Live voice sessions cost just 1 credit per minute.)" | "(Live voice sessions cost 5 credits per minute.)" — drop "just," no longer the cheap framing | + +**Unchanged, explicitly out of scope:** +- `app/(drawer)/(tabs)/characters/[id]/edit.tsx:469` ("Costs 1 credit per sync") — wikiSync, no + price change. +- `app/support.tsx:92` ("Voice replies cost 2 credits per reply") — references the dead + `generateVoiceReply` one-shot path, already slated for full deletion by + `2026-07-01-live-voice-credit-reconciliation-design.md` §3. Will disappear when that spec's + deletion lands; not duplicated here. + +--- + +## What does NOT change + +- `browser_action` tool's own flat voice-path credit (1, separate contextual billing, paused during + wake) — not part of the finalized pricing table. +- Scheduler trigger cost (1, deduped) — unchanged. +- `wikiSync` / `wikiLlm` / `memoryWrite` / `memoryHeal` — unchanged, still 1 each. +- `functions/src/services/creditService.ts` (Functions-side multi-row allocator) — already supports + variable `amount`; not modified, only new call sites added. +- No rate-versioning, no grandfathering of in-flight sessions across the deploy boundary (hard + cutover, see Overview). +- `generateEmbedding`'s `MAX_TEXT_LENGTH` (stays `8_000`) — the `Math.ceil` formula ships now but + only becomes multi-credit-relevant if the cap is raised later. + +--- + +## Testing + +| Area | Test | +|---|---| +| Callables (A) | `summarizeText.test.ts`, `generateEmbedding.test.ts` — new spend/refund coverage (currently untested for billing since unbilled). `convertDocumentText.test.ts`, `generateImage.test.ts` — cost constant bump to 2. `generateReply.test.ts` — grounded (3) vs standard (1) branch, keyed off `tools` presence. | +| Agent loop (B) | `cloud-agent/src/index.test.ts` — per-iteration spend, hard stop at loop 5 with forced summary, refund-on-ADK-error refunds only credits actually spent (not a fixed 1), mid-loop `INSUFFICIENT_CREDITS` degrades to partial reply instead of 500. `browserAction.test.ts` — confirm text-path `browser_action` still skips its own spend (`preBilled`) and isn't double- or un-billed under per-loop billing. | +| Multi-row spend (C) | `cloud-agent/src/services/creditService.test.ts` — new: fragmented-balance 5-credit spend spans multiple rows atomically; insufficient net balance across all rows throws; refund of a multi-row txId array restores all rows. Existing single-credit callers updated to array-shaped return/refund calls: `index.test.ts`, `wsAgentHandler.test.ts`, `wsLiveAgentHandler.test.ts`, `schedulerTriggerHandler.test.ts`. | +| Gate (C) | `wsLiveAgentHandler.test.ts` — connect gate rejects at balance 4, allows at 5. `useLiveVoiceChat` test (client) — `MIN_CREDITS_FOR_CALL` gate at 5. | +| Docs (D) | Manual review — table matches Overview costs exactly, gate line says `≥ 5`. | +| Consumer copy (E) | Manual review — both files say "5 credits per minute," `edit.tsx` and dead-path `support.tsx` line untouched. | + +### Verification commands + +| Suite | Command | Expected | +|---|---|---| +| Functions | `cd functions && npm run typecheck && npm run lint && npm test` | pass | +| Cloud-agent | `cd cloud-agent && npm run typecheck && npm run lint && npm test` | pass | +| Root | `npm run typecheck && npm run lint && npm test` | pass | From ad14a9372ae11d0f1099b99b2777606d10975c0c Mon Sep 17 00:00:00 2001 From: equationalapplications Date: Wed, 1 Jul 2026 22:08:42 -0400 Subject: [PATCH 2/8] docs(spec): fix two verified gaps in credit economy repricing spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generateReply: spend lives in chargeForReply(), which has no access to tools; grounded/standard split needs a signature plumb, not a one-line change at the spend site. - cloud-agent spendCredit ripple: correct the caller inventory — tools/browserAction.ts is the real browser_action spend site (was omitted); wsBrowserAgentHandler.ts is not a caller (was wrongly listed). --- ...6-07-01-credit-economy-repricing-design.md | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md b/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md index 56edd020..84b96718 100644 --- a/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md +++ b/docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md @@ -52,7 +52,7 @@ needed here, only call-site changes. | `generateImage` | `generateImage.ts:127` | `spendCredits(userId, 1)` → `spendCredits(userId, 2)` | | `summarizeText` | `summarizeText.ts` | **New.** Add `spendCredits(userId, 1)` before the Vertex call; `refundCredit` in the existing `catch` around `generateSummary` (handler.ts:155-163). Currently fully unbilled — no chunking exists (single call, ≤16k chars in, one summary out), so the whole call is billed as 1 unit. No block-splitting added. | | `generateEmbedding` | `generateEmbedding.ts` | **New.** Add `spendCredits(userId, Math.ceil(text.length / 50_000))` before the Vertex call; `refundCredit` on failure. `MAX_TEXT_LENGTH` stays `8_000` (unchanged) — the formula always resolves to `1` under that cap today. Shipped as-is anyway: future-proofing for when the cap is raised (e.g. larger-context embedding model or chunking is added later), at which point multi-credit billing activates automatically with no further billing-logic change. | -| `generateReply` | `generateReply.ts:539` | Split by the existing grounded/standard branch already in the code (`generateReply.ts:300-315`, `toGenAITool`/`buildToolsForRequest`): caller passes explicit `tools` → standard → `spendCredits(userId, 1)`. Caller passes no `tools` (defaults to `googleSearchManifest`) → grounded → `spendCredits(userId, 3)`. | +| `generateReply` | `generateReply.ts:535,539,650` | Cost keys off the existing grounded/standard distinction (`generateReply.ts:310-313`, `buildToolsForRequest`): explicit `tools` → standard → 1; no `tools` (defaults to `googleSearchManifest`) → grounded → 3. **Signature change required:** the spend lives in `chargeForReply(userId, credits)` (line 535-539), which currently has **no access to `tools`**. `tools` is parsed in the handler (line 569) and `chargeForReply` is called at line 650. Thread an `isGrounded: boolean` (or the `tools` value) param into `chargeForReply` — computed at the call site as `!tools || tools.length === 0` — and spend `isGrounded ? 3 : 1`. This is not a one-line change at the spend site; it's a small signature plumb from handler → `chargeForReply`. | All four use the existing spend-first / refund-in-catch pattern already proven in convertDocumentText, generateImage, and generateReply — no new error-handling shape. @@ -127,14 +127,23 @@ New signature: `spendCredit(userId: string, amount = 1): Promise` (arr `credit_transactions` row ids, one or more). `refundCredit(userId: string, txIds: string[]): Promise` refunds the full set atomically in one transaction. -**Ripple:** every existing caller of `spendCredit`/`refundCredit` in cloud-agent changes from a -single `string` txId to a `string[]`: -- `POST /agent/run` (Section B) — `amount` always 1 per call now, single-element array, refund logic - updated to pass/spread the array instead of one txId. +**Ripple — every existing caller** of `spendCredit`/`refundCredit` in cloud-agent changes from a +single `string` txId to a `string[]`. Full caller inventory (verified by grep, not assumed): +- `index.ts:261,287,299` (`POST /agent/run`, Section B) — `amount` always 1 per call now, + single-element array; refund logic updated to pass/spread the array instead of one txId. - `wsLiveAgentHandler.ts:339` — `cs.spendCredit(userId, 5)`, one atomic call/transaction per tick (not five sequential 1-credit calls — avoids N+1 query thrashing on a hot 60s-recurring path). -- `wsAgentHandler.ts`, `wsBrowserAgentHandler.ts`, `schedulerTriggerHandler.ts` — signature-only - update (still `amount = 1`), test mocks updated to return/accept arrays. +- `tools/browserAction.ts:96,112,131` — **the actual browser_action spend site** (not the ws + browser handler). Uses `spendCredit(deps.userId)` (default `amount = 1`) and `refundCredit(userId, + txId)` in two places; `txId: string | null` local becomes `string[] | null`. Signature-only + update, but it **won't typecheck** if missed. +- `wsAgentHandler.ts:112,131,175` (+ `refundCredit` at 73) and `schedulerTriggerHandler.ts:187,216,263` + (+ `Pick` type at 96) — signature-only update (still `amount = 1`). +- Test mocks in `index.test.ts`, `wsAgentHandler.test.ts`, `wsLiveAgentHandler.test.ts`, + `schedulerTriggerHandler.test.ts`, `browserAction.test.ts` updated to return/accept arrays. + +`wsBrowserAgentHandler.ts` has **no** `spendCredit` reference — it delegates billing to the +`browser_action` tool (`tools/browserAction.ts`). Not a caller; do not touch. This duplicates FIFO-allocation SQL logic across two codebases (functions/ and cloud-agent/) rather than sharing it — accepted tradeoff to keep the services decoupled and avoid an internal RPC hop on From 808259a2b926fedec4968a75692a8694d59586e1 Mon Sep 17 00:00:00 2001 From: equationalapplications Date: Wed, 1 Jul 2026 22:36:08 -0400 Subject: [PATCH 3/8] docs(plan): add implementation plan for credit economy repricing 11 tasks, dependency-ordered: cloud-agent multi-row credit allocator + ripple, per-loop agent-turn billing with hard stop at 5, live-voice 5-credit tick + gate, client gate, functions callable cost bumps and two new billing sites (summarizeText, generateEmbedding), generateReply grounded/standard split, docs, and consumer copy. --- ...026-07-01-credit-economy-repricing-plan.md | 2509 +++++++++++++++++ 1 file changed, 2509 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-credit-economy-repricing-plan.md diff --git a/docs/superpowers/plans/2026-07-01-credit-economy-repricing-plan.md b/docs/superpowers/plans/2026-07-01-credit-economy-repricing-plan.md new file mode 100644 index 00000000..c79afac7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-credit-economy-repricing-plan.md @@ -0,0 +1,2509 @@ +# Credit Economy Repricing (July 2026) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the finalized July 2026 credit pricing table — metered live-voice and agent-loop billing, new billing on previously-free summarize/embedding calls, a grounded/standard split on chat, and the cloud-agent multi-row credit allocator needed to support >1-credit spends. + +**Architecture:** Three independently-testable areas, in dependency order: (1) `cloud-agent/` — port a multi-row FIFO credit allocator, ripple its new type through five callers, then move agent-turn billing inside the ADK tool loop; (2) `functions/` — bump/add per-callable credit costs, including a new grounded/standard split in `generateReply`; (3) client + docs — connect-gate constant and consumer-facing copy. cloud-agent goes first because its type change is foundational and has zero product-behavior risk on its own (pure refactor + new tests) before the riskier loop-billing change lands on top of it. + +**Tech Stack:** TypeScript, Firebase Functions v2 (`onCall`), Express + `ws` on Cloud Run, Drizzle ORM (raw SQL via `sql` tagged templates in cloud-agent, query builder in functions), `@google/adk` agent runner, Node's built-in `node:test` + `node:assert/strict`, React Native hooks (Jest) for the client hook. + +**Full pricing table, locked decisions (hard cutover, no rate versioning, `LOW_CREDIT_THRESHOLD` stays 5, embedding `MAX_TEXT_LENGTH` stays 8,000), and the "what does NOT change" list all live in the approved spec — read `docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md` before starting if anything below is ambiguous.** + +--- + +## Task 1: Port multi-row FIFO credit allocator into cloud-agent + +**Why first:** cloud-agent's `spendCredit` currently does a single-row atomic decrement and can only ever spend exactly 1 credit. Live voice needs 5 credits/tick, which can span multiple `credit_transactions` rows for a user with a fragmented balance. This task replaces the single-row implementation with the same multi-row FIFO allocator `functions/src/services/creditService.ts` already uses (translated from Drizzle's query builder into cloud-agent's raw-SQL `tx.execute(sql\`...\`)` style, since cloud-agent has no typed Drizzle schema for these tables). + +**Files:** +- Modify: `cloud-agent/src/services/creditService.ts` +- Modify: `cloud-agent/src/services/creditService.test.ts` + +- [ ] **Step 1: Write failing tests for multi-row spend and array-shaped refund** + +Add these tests to `cloud-agent/src/services/creditService.test.ts`, right after the existing `test('spendCredit does not update subscriptions when spend fails', ...)` block (before the `// ── refundCredit ──` comment): + +```typescript +test('spendCredit spans multiple rows when amount exceeds the first row balance', async () => { + // Call 1: INSERT subscriptions + // Call 2: SELECT FOR UPDATE on subscriptions + // Call 3: SELECT SUM(...) net active balance -> '7' (>= 5) + // Call 4: SELECT id, remaining_balance ... FOR UPDATE -> two rows (3, then 4) + // Call 5: UPDATE credit_transactions row tx-1 (- 3) + // Call 6: UPDATE credit_transactions row tx-2 (- 2) + // Call 7: UPDATE subscriptions current_credits cache + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ total: '7' }] }, + { rows: [{ id: 'tx-1', remaining_balance: '3' }, { id: 'tx-2', remaining_balance: '4' }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + const allocations = await cs.spendCredit('user-1', 5) + assert.deepEqual(allocations, [ + { transactionId: 'tx-1', amount: 3 }, + { transactionId: 'tx-2', amount: 2 }, + ]) +}) + +test('spendCredit throws INSUFFICIENT_CREDITS when net balance across all rows is short', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE, Call 3: SELECT SUM -> '3' (< 5) + const db = makeExecuteDb([{ rows: [] }, { rows: [{ user_id: 'user-1' }] }, { rows: [{ total: '3' }] }]) + const cs = createCreditService(db) + await assert.rejects( + () => cs.spendCredit('user-1', 5), + (err: Error) => { + assert.equal(err.message, 'INSUFFICIENT_CREDITS') + return true + }, + ) +}) + +test('spendCredit defaults amount to 1 when not passed', async () => { + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ total: '1' }] }, + { rows: [{ id: 'tx-abc', remaining_balance: '1' }] }, + { rows: [] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + const allocations = await cs.spendCredit('user-1') + assert.deepEqual(allocations, [{ transactionId: 'tx-abc', amount: 1 }]) +}) + +test('refundCredit restores every row in a multi-row allocation atomically', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE subscriptions + // Call 3: UPDATE credit_transactions tx-1 RETURNING id + // Call 4: UPDATE credit_transactions tx-2 RETURNING id + // Call 5: UPDATE subscriptions cache + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ id: 'tx-1' }] }, + { rows: [{ id: 'tx-2' }] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + await assert.doesNotReject(() => + cs.refundCredit('user-1', [ + { transactionId: 'tx-1', amount: 3 }, + { transactionId: 'tx-2', amount: 2 }, + ]), + ) +}) + +test('refundCredit is a no-op for an empty allocation array', async () => { + let executeCalls = 0 + const db = { + execute: async () => { executeCalls++; return { rows: [] } }, + transaction: async (callback: (tx: DrizzleClient) => Promise) => + callback({ execute: async () => { executeCalls++; return { rows: [] } } } as unknown as DrizzleClient), + } as unknown as DrizzleClient + const cs = createCreditService(db) + await cs.refundCredit('user-1', []) + assert.equal(executeCalls, 0) +}) +``` + +Also update the two existing single-row tests immediately above (`'spendCredit returns txId when a qualifying row exists'` and `'refundCredit resolves without throwing'`) to the new shapes: + +```typescript +test('spendCredit returns an allocation array when a qualifying row exists', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE subscriptions + // Call 3: SELECT SUM net active balance, Call 4: SELECT id, remaining_balance FOR UPDATE + // Call 5: UPDATE credit_transactions, Call 6: UPDATE subscriptions cache + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ total: '1' }] }, + { rows: [{ id: 'tx-abc', remaining_balance: '1' }] }, + { rows: [] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + const allocations = await cs.spendCredit('user-1', 1) + assert.deepEqual(allocations, [{ transactionId: 'tx-abc', amount: 1 }]) +}) +``` + +```typescript +test('refundCredit resolves without throwing', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE subscriptions + // Call 3: UPDATE credit_transactions RETURNING id, Call 4: UPDATE subscriptions cache + const db = makeExecuteDb([{ rows: [] }, { rows: [{ user_id: 'user-1' }] }, { rows: [{ id: 'tx-abc' }] }, { rows: [] }]) + const cs = createCreditService(db) + await assert.doesNotReject(() => cs.refundCredit('user-1', [{ transactionId: 'tx-abc', amount: 1 }])) +}) +``` + +And the `'refundCredit makes correct number of execute calls'` test's call to `cs.refundCredit('user-1', 'tx-abc')` becomes `cs.refundCredit('user-1', [{ transactionId: 'tx-abc', amount: 1 }])` (same execute-call-count assertion logic still applies, one row instead of a raw string). + +- [ ] **Step 2: Run tests to verify they fail (compile error expected — types don't match yet)** + +Run: `cd cloud-agent && npm run build 2>&1 | head -40` +Expected: TypeScript errors on `spendCredit`/`refundCredit` call sites in the test file — `Argument of type 'string' is not assignable to parameter of type 'number'` (old signature took `(userId)` only) or similar, since the implementation hasn't changed yet. + +- [ ] **Step 3: Port the multi-row FIFO allocator into the implementation** + +Replace the full contents of `cloud-agent/src/services/creditService.ts`: + +```typescript +import { sql } from 'drizzle-orm' +import type { DrizzleClient } from '../db/client.js' + +export type CreditSpendAllocation = { + transactionId: string + amount: number +} + +export type CreditService = { + spendCredit: (userId: string, amount?: number) => Promise + refundCredit: (userId: string, allocations: CreditSpendAllocation[]) => Promise + getBalance: (userId: string) => Promise +} + +export function createCreditService(db: DrizzleClient): CreditService { + return { + async spendCredit(userId: string, amount = 1): Promise { + // Match functions/ lock order to prevent deadlocks: + // 1. Ensure subscriptions row exists and lock it first + // 2. Then lock and update credit_transactions + return await db.transaction(async (tx) => { + await tx.execute(sql` + INSERT INTO subscriptions (user_id, current_credits) + VALUES (${userId}, 0) + ON CONFLICT (user_id) DO NOTHING + `) + + await tx.execute(sql` + SELECT user_id FROM subscriptions + WHERE user_id = ${userId} + FOR UPDATE + `) + + // Ensure the user has a non-negative net active balance (adjustments can create negative rows). + const netResult = await tx.execute<{ total: string | null }>(sql` + SELECT GREATEST(COALESCE(SUM(remaining_balance), 0), 0) AS total + FROM credit_transactions + WHERE user_id = ${userId} + AND (expires_at IS NULL OR expires_at > NOW()) + `) + const netCredits = Number(netResult.rows[0]?.total ?? 0) + if (netCredits < amount) { + throw new Error('INSUFFICIENT_CREDITS') + } + + // Lock every qualifying row FIFO (expiring soonest first), then allocate + // the requested amount across as many rows as needed. + const rows = await tx.execute<{ id: string; remaining_balance: string }>(sql` + SELECT id, remaining_balance FROM credit_transactions + WHERE user_id = ${userId} + AND remaining_balance > 0 + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY expires_at ASC NULLS LAST, id ASC + FOR UPDATE + `) + + let remaining = amount + const allocations: CreditSpendAllocation[] = [] + for (const row of rows.rows) { + if (remaining <= 0) break + const take = Math.min(Number(row.remaining_balance), remaining) + await tx.execute(sql` + UPDATE credit_transactions + SET remaining_balance = remaining_balance - ${take} + WHERE id = ${row.id} + `) + allocations.push({ transactionId: row.id, amount: take }) + remaining -= take + } + + if (remaining > 0 || allocations.length === 0) { + // Net balance passed under lock but rows could not cover it — should be unreachable. + throw new Error('INSUFFICIENT_CREDITS') + } + + // Update subscriptions cache (row is already locked) + try { + await tx.execute(sql` + UPDATE subscriptions + SET current_credits = ( + SELECT GREATEST(COALESCE(SUM(remaining_balance), 0), 0) + FROM credit_transactions + WHERE user_id = ${userId} + AND (expires_at IS NULL OR expires_at > NOW()) + ) + WHERE user_id = ${userId} + `) + } catch (err) { + // Best-effort cache sync; credit_transactions is the source of truth. + console.warn(`subscriptions.current_credits decrement failed user=${userId}`, err) + } + + return allocations + }) + }, + + async refundCredit(userId: string, allocations: CreditSpendAllocation[]): Promise { + if (allocations.length === 0) { + return + } + + await db.transaction(async (tx) => { + await tx.execute(sql` + INSERT INTO subscriptions (user_id, current_credits) + VALUES (${userId}, 0) + ON CONFLICT (user_id) DO NOTHING + `) + + await tx.execute(sql` + SELECT user_id FROM subscriptions + WHERE user_id = ${userId} + FOR UPDATE + `) + + for (const { transactionId, amount } of allocations) { + const updated = await tx.execute<{ id: string }>(sql` + UPDATE credit_transactions + SET remaining_balance = remaining_balance + ${amount} + WHERE id = ${transactionId} + AND user_id = ${userId} + AND (expires_at IS NULL OR expires_at > NOW()) + RETURNING id + `) + + if (updated.rows.length === 0) { + // Original row expired between spend and refund; insert a non-expiring compensation. + await tx.execute(sql` + INSERT INTO credit_transactions ( + user_id, delta, reason, initial_amount, remaining_balance, transaction_type, expires_at + ) + VALUES (${userId}, ${amount}, 'refund_compensation', ${amount}, ${amount}, 'legacy', NULL) + `) + } + } + + try { + await tx.execute(sql` + UPDATE subscriptions + SET current_credits = ( + SELECT GREATEST(COALESCE(SUM(remaining_balance), 0), 0) + FROM credit_transactions + WHERE user_id = ${userId} + AND (expires_at IS NULL OR expires_at > NOW()) + ) + WHERE user_id = ${userId} + `) + } catch (err) { + console.warn(`subscriptions.current_credits increment failed user=${userId}`, err) + } + }) + }, + + async getBalance(userId: string): Promise { + const result = await db.execute<{ total: string | null }>(sql` + SELECT GREATEST(COALESCE(SUM(remaining_balance), 0), 0) AS total + FROM credit_transactions + WHERE user_id = ${userId} + AND (expires_at IS NULL OR expires_at > NOW()) + `) + const total = result.rows[0]?.total + return total !== null && total !== undefined ? Number(total) : 0 + }, + } +} +``` + +Note the original single-row version's INSERT/lock block appeared both in `spendCredit` and `refundCredit`; the port keeps that duplication (matches the original file's structure, not introducing a new shared helper — YAGNI, this is a 2-call-site duplication, not worth abstracting). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd cloud-agent && npm test -- --test-name-pattern="spendCredit|refundCredit|getBalance"` +Expected: all tests in `creditService.test.ts` pass (the `getBalance` tests are unaffected by this change and should already pass). + +- [ ] **Step 5: Commit** + +```bash +cd cloud-agent +git add src/services/creditService.ts src/services/creditService.test.ts +git commit -m "feat(cloud-agent): port multi-row FIFO credit allocator, support amount > 1" +``` + +--- + +## Task 2: Ripple the new CreditService type through the three simple callers + +**Why:** `spendCredit` now returns `CreditSpendAllocation[]` instead of `string`, and `refundCredit` takes that array instead of a raw txId. Three callers use the service in a simple spend-once/refund-once pattern with no other behavior change: `browserAction.ts`, `wsAgentHandler.ts`, `schedulerTriggerHandler.ts`. This task is a pure type-signature ripple — no product behavior changes, all three still spend exactly 1 credit as before. + +**Files:** +- Modify: `cloud-agent/src/tools/browserAction.ts` +- Modify: `cloud-agent/src/tools/browserAction.test.ts` +- Modify: `cloud-agent/src/handlers/wsAgentHandler.ts` +- Modify: `cloud-agent/src/handlers/wsAgentHandler.test.ts` +- Modify: `cloud-agent/src/handlers/schedulerTriggerHandler.ts` +- Modify: `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts` + +- [ ] **Step 1: Update `browserAction.ts` and its test** + +In `cloud-agent/src/tools/browserAction.ts`, update the import (line 5): + +```typescript +import type { CreditService } from '../services/creditService.js' +``` +→ +```typescript +import type { CreditService, CreditSpendAllocation } from '../services/creditService.js' +``` + +Line 93 — `let txId: string | null = null` → `let allocations: CreditSpendAllocation[] | null = null` + +Line 96 — `try { txId = await deps.creditService.spendCredit(deps.userId) }` → `try { allocations = await deps.creditService.spendCredit(deps.userId) }` + +Lines 111-113: +```typescript + if (txId) { + try { await deps.creditService.refundCredit(deps.userId, txId) } catch { /* logged */ } + } +``` +→ +```typescript + if (allocations) { + try { await deps.creditService.refundCredit(deps.userId, allocations) } catch { /* logged */ } + } +``` + +Line 131: +```typescript + if (txId) { try { await deps.creditService.refundCredit(deps.userId, txId) } catch { /* logged */ } } +``` +→ +```typescript + if (allocations) { try { await deps.creditService.refundCredit(deps.userId, allocations) } catch { /* logged */ } } +``` + +In `cloud-agent/src/tools/browserAction.test.ts`, three mock definitions change shape: + +Line 29: `creditService: { spendCredit: async () => { calls.spend++; return 'tx1' }, refundCredit: async () => { calls.refund++ } },` +→ `creditService: { spendCredit: async () => { calls.spend++; return [{ transactionId: 'tx1', amount: 1 }] }, refundCredit: async () => { calls.refund++ } },` + +Line 158 and line 196 (identical text, both instances): `creditService: { spendCredit: async () => 'tx1', refundCredit: async () => {} } as never,` +→ `creditService: { spendCredit: async () => [{ transactionId: 'tx1', amount: 1 }], refundCredit: async () => {} } as never,` + +Use `replace_all` for the line-158/196 edit since both occurrences get the identical replacement. + +- [ ] **Step 2: Update `wsAgentHandler.ts` and its test** + +In `cloud-agent/src/handlers/wsAgentHandler.ts`, update the import (line 15): + +```typescript +import type { CreditService } from '../services/creditService.js' +``` +→ +```typescript +import type { CreditService, CreditSpendAllocation } from '../services/creditService.js' +``` + +Line 57 — `let activeTxId: string | null = null` → `let activeAllocations: CreditSpendAllocation[] | null = null` + +Lines 70-79: +```typescript + const refundIfNeeded = async () => { + if (userId && activeTxId && !isCompleted) { + try { + await cs.refundCredit(userId, activeTxId) + } catch (refundErr) { + console.error(`[CRITICAL] WS refundCredit failed user=${userId} txId=${activeTxId}`, refundErr) + } + activeTxId = null + } + } +``` +→ +```typescript + const refundIfNeeded = async () => { + if (userId && activeAllocations && !isCompleted) { + try { + await cs.refundCredit(userId, activeAllocations) + } catch (refundErr) { + console.error(`[CRITICAL] WS refundCredit failed user=${userId}`, refundErr) + } + activeAllocations = null + } + } +``` + +Lines 110-123: +```typescript + let txId: string + try { + txId = await cs.spendCredit(userId) + } catch (creditErr: unknown) { + const msg = creditErr instanceof Error ? creditErr.message : '' + if (msg === 'INSUFFICIENT_CREDITS') { + ws.send(JSON.stringify({ type: 'error', code: 'INSUFFICIENT_CREDITS', message: 'Insufficient credits' })) + ws.close(4402, 'Insufficient credits') + return + } + throw creditErr + } + + activeTxId = txId +``` +→ +```typescript + let allocations: CreditSpendAllocation[] + try { + allocations = await cs.spendCredit(userId) + } catch (creditErr: unknown) { + const msg = creditErr instanceof Error ? creditErr.message : '' + if (msg === 'INSUFFICIENT_CREDITS') { + ws.send(JSON.stringify({ type: 'error', code: 'INSUFFICIENT_CREDITS', message: 'Insufficient credits' })) + ws.close(4402, 'Insufficient credits') + return + } + throw creditErr + } + + activeAllocations = allocations +``` + +Lines 130-135: +```typescript + if (!character) { + await cs.refundCredit(userId, txId) + activeTxId = null +``` +→ +```typescript + if (!character) { + await cs.refundCredit(userId, allocations) + activeAllocations = null +``` + +Lines 174-176: +```typescript + } catch (preAgentErr) { + await cs.refundCredit(userId, txId) + activeTxId = null +``` +→ +```typescript + } catch (preAgentErr) { + await cs.refundCredit(userId, allocations) + activeAllocations = null +``` + +Lines 164-165 and 282-283 (identical `isCompleted = true\n activeTxId = null` text, both occurrences): use `replace_all` to change `activeTxId = null` → `activeAllocations = null` in that pattern. + +In `cloud-agent/src/handlers/wsAgentHandler.test.ts`, lines 42-44: +```typescript +const mockCreditService = { + spendCredit: async (_userId: string): Promise => 'mock-txid', + refundCredit: async (_userId: string, _txId: string): Promise => {}, +} +``` +→ +```typescript +const mockCreditService = { + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 1 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, +} +``` + +Line 237 (`spendCredit: async (): Promise => { throw new Error('INSUFFICIENT_CREDITS') },`) is unaffected — still throws, no return-shape dependency. + +- [ ] **Step 3: Update `schedulerTriggerHandler.ts` and its test** + +In `cloud-agent/src/handlers/schedulerTriggerHandler.ts`, update the import (line 6): + +```typescript +import type { CreditService } from '../services/creditService.js' +``` +→ +```typescript +import type { CreditService, CreditSpendAllocation } from '../services/creditService.js' +``` + +(The `Pick` parameter type at line 96 needs no edit — it adapts automatically once the underlying `CreditService` type changes.) + +Lines 184-198: +```typescript + let txId: string | null = null + if (!isDuplicateRun) { + try { + txId = await creditService.spendCredit(userId) + } catch (err) { +``` +→ +```typescript + let allocations: CreditSpendAllocation[] | null = null + if (!isDuplicateRun) { + try { + allocations = await creditService.spendCredit(userId) + } catch (err) { +``` +(rest of that catch block is unchanged) + +Lines 215-217: +```typescript + if (txId) { + try { await creditService.refundCredit(userId, txId) } catch { /* logged */ } + } +``` +→ +```typescript + if (allocations) { + try { await creditService.refundCredit(userId, allocations) } catch { /* logged */ } + } +``` + +Lines 262-264: +```typescript + if (!isDuplicateRun && abortedOffline && txId) { + try { await creditService.refundCredit(userId, txId) } catch { /* logged */ } + } +``` +→ +```typescript + if (!isDuplicateRun && abortedOffline && allocations) { + try { await creditService.refundCredit(userId, allocations) } catch { /* logged */ } + } +``` + +In `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts`, lines 27-28: +```typescript + spendCredit?: () => Promise + refundCredit?: () => Promise +``` +→ +```typescript + spendCredit?: () => Promise<{ transactionId: string; amount: number }[]> + refundCredit?: () => Promise +``` + +Lines 67-68: +```typescript + spendCredit: overrides.spendCredit ?? (async () => { creditCalls.spend++; return 'tx1' }), + refundCredit: overrides.refundCredit ?? (async () => { creditCalls.refund++ }), +``` +→ +```typescript + spendCredit: overrides.spendCredit ?? (async () => { creditCalls.spend++; return [{ transactionId: 'tx1', amount: 1 }] }), + refundCredit: overrides.refundCredit ?? (async () => { creditCalls.refund++ }), +``` + +Line 150 (`spendCredit: async () => { throw new Error('INSUFFICIENT_CREDITS') },`) is unaffected. + +- [ ] **Step 4: Typecheck and run all three test files** + +Run: `cd cloud-agent && npm run typecheck` +Expected: no errors. + +Run: `cd cloud-agent && npm test -- --test-name-pattern="browser_action|browserAction|wsAgentHandler|scheduler-trigger|scheduler trigger"` +Expected: all tests pass unchanged (this task changes types and variable names only, not behavior — every existing assertion should still hold). + +If the name-pattern filter doesn't match cleanly, fall back to the full suite for this step: `npm test 2>&1 | tail -60` and confirm no failures in `browserAction.test.ts`, `wsAgentHandler.test.ts`, or `schedulerTriggerHandler.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +cd cloud-agent +git add src/tools/browserAction.ts src/tools/browserAction.test.ts src/handlers/wsAgentHandler.ts src/handlers/wsAgentHandler.test.ts src/handlers/schedulerTriggerHandler.ts src/handlers/schedulerTriggerHandler.test.ts +git commit -m "refactor(cloud-agent): ripple CreditSpendAllocation[] type through browserAction, wsAgentHandler, schedulerTriggerHandler" +``` + +--- + +## Task 3: wsLiveAgentHandler — 5-credit tick, gate raised to 5 + +**Files:** +- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.ts` +- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` + +- [ ] **Step 1: Update the shared mock and two override closures in the test file** + +In `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts`, lines 44-48: +```typescript +const mockCreditService = { + spendCredit: async (_userId: string): Promise => 'mock-txid', + refundCredit: async (_userId: string, _txId: string): Promise => {}, + getBalance: async (_userId: string): Promise => 42, +} +``` +→ +```typescript +const mockCreditService = { + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 5 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, + getBalance: async (_userId: string): Promise => 42, +} +``` + +Lines 734-738 (`'billing tick with INSUFFICIENT_CREDITS...'` test override) — `spendCredit: async (): Promise => { throw new Error('INSUFFICIENT_CREDITS') },` needs no shape change (still throws), only the return type annotation reads oddly with the old `Promise` — update it to `Promise<{ transactionId: string; amount: number }[]>` for consistency: +```typescript + spendCredit: async (): Promise<{ transactionId: string; amount: number }[]> => { + throw new Error('INSUFFICIENT_CREDITS') + }, +``` + +Lines 780-784 (`'billing ticks do not overlap...'` test override): +```typescript + spendCredit: async (): Promise => { + spendCalls++ + await new Promise((resolve) => setTimeout(resolve, 120)) + return 'mock-txid' + }, +``` +→ +```typescript + spendCredit: async (): Promise<{ transactionId: string; amount: number }[]> => { + spendCalls++ + await new Promise((resolve) => setTimeout(resolve, 120)) + return [{ transactionId: 'mock-txid', amount: 5 }] + }, +``` + +- [ ] **Step 2: Add two new gate-boundary tests** + +Add these right after the existing `'one credit at open closes with 4402'` test (around line 274): + +```typescript +test('four credits at open closes with 4402 (below new gate)', async () => { + const db = makeMockDb([[mockUser]]) + const cs = { ...mockCreditService, getBalance: async () => 4 } + const mock = makeMockLiveConnect() + const { server, close } = createLiveTestServer({ + db, + creditService: cs, + verifyToken: async () => ({ uid: 'uid' }), + liveConnect: mock.connect, + }) + const port = await listen(server) + + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + const timeout = setTimeout(() => reject(new Error('test timeout')), 5000) + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID })) + }) + ws.on('close', (code) => { + clearTimeout(timeout) + assert.equal(code, 4402) + resolve() + }) + ws.on('error', reject) + }) + + await close() +}) + +test('five credits at open allows the session to proceed', async () => { + const db = makeMockDb([[mockUser], [mockCharacter]]) + const cs = { ...mockCreditService, getBalance: async () => 5 } + const mock = makeMockLiveConnect() + const { server, close } = createLiveTestServer({ + db, + creditService: cs, + verifyToken: async () => ({ uid: 'uid' }), + liveConnect: mock.connect, + billingIntervalMs: 60_000, + }) + const port = await listen(server) + + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + const timeout = setTimeout(() => reject(new Error('test timeout')), 5000) + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID })) + }) + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as { type: string; remainingCredits?: number } + if (msg.type === 'session_ready') { + clearTimeout(timeout) + assert.equal(msg.remainingCredits, 5) + ws.close() + resolve() + } + }) + ws.on('error', reject) + }) + + await close() +}) +``` + +- [ ] **Step 3: Run tests to verify the new gate tests fail (server still gates at 2)** + +Run: `cd cloud-agent && npm run build 2>&1 | tail -40` + +This should build clean (only test additions so far, real logic unchanged). Then: + +Run: `cd cloud-agent && npm test -- --test-name-pattern="four credits|five credits"` +Expected: `'four credits at open closes with 4402'` passes trivially (still true under old gate too), but `'five credits at open allows the session to proceed'` FAILS — the server still gates at `balance < 2`, so 5 credits already passes today. **This means the "five credits" test doesn't actually prove anything new yet without also testing a value between the old and new gate.** Replace it: to get a real red-then-green cycle, temporarily change the test's `getBalance` to `async () => 3` (passes old gate of 2, should be REJECTED under the new gate of 5) before implementing, confirm it currently passes (wrongly, since old code allows balance=3), then implement the gate change, then flip the assertion to expect a 4402 close, then after the source fix confirms rejection, revert the test back to the `5`-credits-allowed version, which will now correctly fail until the tick amount is also fixed to spend 5 (see next step) — OR, more simply: skip the manual red/green dance here and go straight to Step 4, then run the full boundary pair (4402 at 4, success at 5) together as the verification in Step 5. This tick-billing change is small enough that a strict single-test red/green isn't worth the churn; verify both boundary tests as a pair after the implementation step. + +- [ ] **Step 4: Implement the gate and tick amount changes** + +In `cloud-agent/src/handlers/wsLiveAgentHandler.ts`, line 316: +```typescript + if (balance < 2) { +``` +→ +```typescript + if (balance < 5) { +``` + +Line 339: +```typescript + await cs.spendCredit(userId!) +``` +→ +```typescript + await cs.spendCredit(userId!, 5) +``` + +- [ ] **Step 5: Run the full gate + tick test set to verify pass** + +Run: `cd cloud-agent && npm test -- --test-name-pattern="credit|4402|session_ready|billing"` +Expected: all pass, including the two new boundary tests (`'four credits at open closes with 4402 (below new gate)'` and `'five credits at open allows the session to proceed'`). + +Run the full file to be safe: `cd cloud-agent && npm run build && NODE_ENV=test node --test --test-reporter spec "dist/handlers/wsLiveAgentHandler.test.js"` +Expected: all ~35+ tests in the file pass. + +- [ ] **Step 6: Commit** + +```bash +cd cloud-agent +git add src/handlers/wsLiveAgentHandler.ts src/handlers/wsLiveAgentHandler.test.ts +git commit -m "feat(cloud-agent): live voice bills 5 credits/tick, connect gate raised to 5" +``` + +--- + +## Task 4: Move agent-turn billing inside the ADK tool loop, hard-stop at 5 + +**Why this shape:** `runAgentReal` (the production `runAgentFn`) currently has zero direct unit tests — it's only exercised end-to-end via `index.test.ts`'s route tests using a *mocked* `runAgentFn`, which bypasses the real ADK loop entirely. To make the new per-loop billing behavior actually testable, this task extracts the event-consuming loop into a new, directly-testable pure function, `consumeAgentEvents`, that takes an `AsyncIterable` of ADK events plus a credit service and returns the same `{reply, toolCalls, groundingMetadata}` shape. `runAgentReal` becomes a thin wrapper that builds the ADK runner/session and delegates event consumption to it. + +**Files:** +- Create: `cloud-agent/src/services/agentEventLoop.ts` +- Create: `cloud-agent/src/services/agentEventLoop.test.ts` +- Modify: `cloud-agent/src/index.ts` +- Modify: `cloud-agent/src/index.test.ts` + +- [ ] **Step 1: Write failing tests for `consumeAgentEvents`** + +Create `cloud-agent/src/services/agentEventLoop.test.ts`: + +```typescript +import assert from 'node:assert/strict' +import test from 'node:test' +import type { Event as AdkEvent } from '@google/adk' +import type { CreditSpendAllocation } from './creditService.js' + +const { consumeAgentEvents } = await import('./agentEventLoop.js') + +function fakeEvent(overrides: Partial = {}): AdkEvent { + return { + id: `evt-${Math.random()}`, + invocationId: 'inv-1', + actions: {}, + ...overrides, + } as AdkEvent +} + +function textEvent(text: string): AdkEvent { + return fakeEvent({ content: { role: 'model', parts: [{ text }] } }) +} + +function functionCallEvent(name: string): AdkEvent { + return fakeEvent({ content: { role: 'model', parts: [{ functionCall: { name, args: {} } }] } }) +} + +async function* toAsyncIterable(events: AdkEvent[]): AsyncGenerator { + for (const e of events) yield e +} + +const FALLBACK_REPLY = "I've done what I can for now — let me know if you'd like me to continue." + +test('consumeAgentEvents spends 1 credit per functionCall-bearing event and returns the final reply', async () => { + const spendCalls: string[] = [] + const cs = { + spendCredit: async (userId: string) => { + spendCalls.push(userId) + return [{ transactionId: `tx-${spendCalls.length}`, amount: 1 }] as CreditSpendAllocation[] + }, + refundCredit: async () => {}, + } + const events = toAsyncIterable([functionCallEvent('get_current_time'), textEvent('It is 3pm.')]) + const result = await consumeAgentEvents(events, 'user-1', cs) + assert.equal(spendCalls.length, 1) + assert.deepEqual(spendCalls, ['user-1']) + assert.equal(result.reply, 'It is 3pm.') + assert.deepEqual(result.toolCalls, ['get_current_time']) +}) + +test('consumeAgentEvents hard-stops at 5 loop iterations and returns a fallback reply', async () => { + const cs = { + spendCredit: async () => [{ transactionId: 'tx', amount: 1 }] as CreditSpendAllocation[], + refundCredit: async () => {}, + } + const events = toAsyncIterable([ + functionCallEvent('tool_a'), + functionCallEvent('tool_b'), + functionCallEvent('tool_c'), + functionCallEvent('tool_d'), + functionCallEvent('tool_e'), + functionCallEvent('tool_f'), // never consumed — loop stops after the 5th + ]) + const result = await consumeAgentEvents(events, 'user-1', cs) + assert.equal(result.toolCalls.length, 5) + assert.equal(result.reply, FALLBACK_REPLY) +}) + +test('consumeAgentEvents degrades gracefully when credits run out mid-loop (no throw, no refund)', async () => { + let calls = 0 + const cs = { + spendCredit: async () => { + calls += 1 + if (calls === 2) throw new Error('INSUFFICIENT_CREDITS') + return [{ transactionId: `tx-${calls}`, amount: 1 }] as CreditSpendAllocation[] + }, + refundCredit: async () => { + throw new Error('refundCredit should not be called on a graceful mid-loop degrade') + }, + } + const events = toAsyncIterable([ + functionCallEvent('tool_a'), + functionCallEvent('tool_b'), // this spend throws INSUFFICIENT_CREDITS + functionCallEvent('tool_c'), // never reached + ]) + const result = await consumeAgentEvents(events, 'user-1', cs) + assert.equal(result.toolCalls.length, 2) + assert.equal(result.reply, FALLBACK_REPLY) +}) + +test('consumeAgentEvents refunds only the credits spent this turn on a genuine ADK error', async () => { + let calls = 0 + const refunded: unknown[] = [] + const cs = { + spendCredit: async () => { + calls += 1 + return [{ transactionId: `tx-${calls}`, amount: 1 }] as CreditSpendAllocation[] + }, + refundCredit: async (_userId: string, allocations: CreditSpendAllocation[]) => { + refunded.push(allocations) + }, + } + const events = toAsyncIterable([ + functionCallEvent('tool_a'), + functionCallEvent('tool_b'), + fakeEvent({ errorCode: 'SAFETY', errorMessage: 'blocked' }), + ]) + await assert.rejects( + () => consumeAgentEvents(events, 'user-1', cs), + (err: Error) => { + assert.match(err.message, /ADK error \(SAFETY\)/) + return true + }, + ) + assert.deepEqual(refunded, [[ + { transactionId: 'tx-1', amount: 1 }, + { transactionId: 'tx-2', amount: 1 }, + ]]) +}) + +test('consumeAgentEvents throws when the loop completes normally with an empty final reply', async () => { + const cs = { + spendCredit: async () => [{ transactionId: 'tx', amount: 1 }] as CreditSpendAllocation[], + refundCredit: async () => {}, + } + const events = toAsyncIterable([fakeEvent({ content: { role: 'model', parts: [{ text: '' }] } })]) + await assert.rejects( + () => consumeAgentEvents(events, 'user-1', cs), + (err: Error) => { + assert.equal(err.message, 'ADK returned an empty final reply') + return true + }, + ) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail (module doesn't exist yet)** + +Run: `cd cloud-agent && npm run build 2>&1 | head -20` +Expected: `Cannot find module './agentEventLoop.js'` or equivalent. + +- [ ] **Step 3: Implement `consumeAgentEvents`** + +Create `cloud-agent/src/services/agentEventLoop.ts`: + +```typescript +import { isFinalResponse } from '@google/adk' +import type { Event as AdkEvent } from '@google/adk' +import type { GroundingMetadata } from '@google/genai' +import { hasGroundingData } from '../groundingMetadata.js' +import type { CreditService, CreditSpendAllocation } from './creditService.js' + +const MAX_LOOP_ITERATIONS = 5 +const DEGRADED_FALLBACK_REPLY = + "I've done what I can for now — let me know if you'd like me to continue." + +export interface ConsumeAgentEventsResult { + reply: string + toolCalls: string[] + groundingMetadata?: GroundingMetadata +} + +function eventHasFunctionCall(event: AdkEvent): boolean { + return event.content?.parts?.some((part) => 'functionCall' in part) ?? false +} + +function extractText(event: AdkEvent): string { + if (!event.content?.parts) return '' + return event.content.parts + .filter((p): p is { text: string } => 'text' in p) + .map((p) => p.text) + .join('') +} + +/** + * Consumes one ADK agent run's event stream, billing 1 credit per internal + * tool-call loop iteration (capped at MAX_LOOP_ITERATIONS) instead of a flat + * per-turn charge. Hitting the cap, or running out of credits mid-loop, stops + * the stream early and returns a graceful fallback reply rather than throwing — + * only a genuine ADK error refunds the credits already spent this turn. + */ +export async function consumeAgentEvents( + events: AsyncIterable, + userId: string, + creditService: Pick, +): Promise { + let reply = '' + let lastText = '' + const toolCalls: string[] = [] + let groundingMetadata: GroundingMetadata | undefined + let loopCount = 0 + let degraded = false + const spentAllocations: CreditSpendAllocation[] = [] + + try { + for await (const event of events) { + if (event.errorCode || event.errorMessage) { + throw new Error(`ADK error (${event.errorCode ?? 'unknown'}): ${event.errorMessage ?? 'no message'}`) + } + + if (eventHasFunctionCall(event)) { + for (const part of event.content!.parts!) { + if ('functionCall' in part) { + const fc = (part as { functionCall?: { name?: string } }).functionCall + if (fc?.name) toolCalls.push(fc.name) + } + } + + loopCount += 1 + try { + const allocations = await creditService.spendCredit(userId) + spentAllocations.push(...allocations) + } catch (creditErr) { + const msg = creditErr instanceof Error ? creditErr.message : '' + if (msg === 'INSUFFICIENT_CREDITS') { + degraded = true + break + } + throw creditErr + } + + if (loopCount === MAX_LOOP_ITERATIONS) { + degraded = true + break + } + } + + if (hasGroundingData(event.groundingMetadata)) { + groundingMetadata = event.groundingMetadata + } + + const text = extractText(event) + if (text) lastText = text + + if (isFinalResponse(event) && event.content?.parts) { + reply = text + } + } + + if (!reply.trim()) { + if (degraded) { + reply = lastText.trim() || DEGRADED_FALLBACK_REPLY + } else { + throw new Error('ADK returned an empty final reply') + } + } + } catch (err) { + if (spentAllocations.length > 0) { + try { + await creditService.refundCredit(userId, spentAllocations) + } catch (refundErr) { + console.error(`[CRITICAL] refundCredit failed user=${userId}`, refundErr) + } + } + throw err + } + + return { reply, toolCalls, groundingMetadata } +} +``` + +- [ ] **Step 4: Run `agentEventLoop.test.ts` to verify pass** + +Run: `cd cloud-agent && npm run build && NODE_ENV=test node --test --test-reporter spec "dist/services/agentEventLoop.test.js"` +Expected: all 5 tests pass. + +- [ ] **Step 5: Commit the new module** + +```bash +cd cloud-agent +git add src/services/agentEventLoop.ts src/services/agentEventLoop.test.ts +git commit -m "feat(cloud-agent): add consumeAgentEvents — per-loop credit billing with hard stop at 5" +``` + +- [ ] **Step 6: Wire `runAgentReal` to use `consumeAgentEvents`, and add `creditService` to `RunAgentParams`** + +In `cloud-agent/src/index.ts`, update the `@google/adk` import (line 7): +```typescript +import { InMemoryRunner, isFinalResponse, createEvent, createEventActions } from '@google/adk' +``` +→ +```typescript +import { InMemoryRunner, createEvent, createEventActions } from '@google/adk' +``` + +Remove the now-unused `hasGroundingData` import (line 12) entirely — it's only used inside the loop being extracted: +```typescript +import { hasGroundingData } from './groundingMetadata.js' +``` +(delete this line) + +Add the new import alongside the other `./services/*` imports: +```typescript +import { consumeAgentEvents } from './services/agentEventLoop.js' +``` + +Update `RunAgentParams` (lines 41-51): +```typescript +export interface RunAgentParams { + db: DrizzleClient + userId: string + firebaseUid: string + characterId: string + systemInstruction: string + message: string + history: Content[] + timezone: string + embed: (text: string) => Promise +} +``` +→ +```typescript +export interface RunAgentParams { + db: DrizzleClient + userId: string + firebaseUid: string + characterId: string + systemInstruction: string + message: string + history: Content[] + timezone: string + embed: (text: string) => Promise + creditService: Pick +} +``` + +Replace the body of `runAgentReal` (lines 65-138) with the thin version: +```typescript +export async function runAgentReal(params: RunAgentParams): Promise<{ reply: string; toolCalls: string[]; groundingMetadata?: GroundingMetadata }> { + const { db, userId, firebaseUid, characterId, systemInstruction, message, history, timezone, embed, creditService } = params + const bridge = admin.apps.length ? { + firebaseUid, + userId, + firestoreSession: defaultFirestoreSession(), + fcmDispatcher: defaultFcmDispatcher(), + creditService: createCreditService(db), + instanceId: INSTANCE_ID, + } : undefined + const agent = buildAgent(db, userId, characterId, systemInstruction, timezone, embed, bridge) + const runner = new InMemoryRunner({ agent, appName: 'clanker-cloud-agent' }) + const sessionId = crypto.randomUUID() + + const session = await runner.sessionService.createSession({ + appName: 'clanker-cloud-agent', + userId, + sessionId, + }) + + if (history.length > 0) { + for (const turn of history) { + await runner.sessionService.appendEvent({ + session, + event: createEvent({ + invocationId: crypto.randomUUID(), + author: turn.role === 'user' ? 'user' : agent.name, + content: turn, + actions: createEventActions(), + }), + }) + } + } + + const events = runner.runAsync({ + userId, + sessionId, + newMessage: { role: 'user', parts: [{ text: message }] }, + }) + + return consumeAgentEvents(events, userId, creditService) +} +``` + +(Note: `bridge.creditService: createCreditService(db)` is left untouched — that's a separate instance used by the `browser_action` tool's own contextual billing inside `buildAgent`, unrelated to this turn-level billing seam. Not in scope for this task.) + +- [ ] **Step 7: Remove pre-loop spend/refund from the `POST /agent/run` route handler** + +In `cloud-agent/src/index.ts`, replace the block from `// SPEND FIRST` through the `// 4. RESPOND` `res.json(...)` call (originally lines 258-320) with: + +```typescript + if (unsyncedHistory.length > 0) { + try { + await bulkInsertUnsynced(db, userId, characterId, unsyncedHistory, embedText) + } catch (err) { + // Swallow sync errors so the agent can still respond (matches Firebase generateReply behavior) + console.error('bulkInsertUnsynced failed:', err) + } + } + + const wikiContext = await queryWikiContext(db, message, userId, characterId, embedText) + const systemInstruction = assembleSystemInstruction(character, wikiContext) + + // Credit spend now happens per internal ADK loop iteration inside runAgentFn + // (see services/agentEventLoop.ts) — refund-on-failure is handled there too. + const result = await runAgentFn({ db, userId, firebaseUid, characterId, systemInstruction, message, history, timezone, embed: embedText, creditService: cs }) + + // GET BALANCE — graceful degrade if this fails + let newBalance: number | null = null + try { + newBalance = await cs.getBalance(userId) + } catch (balErr) { + console.warn(`getBalance failed user=${userId}, returning null snapshot`, balErr) + } + + // RESPOND + res.json({ + reply: result.reply, + toolCalls: result.toolCalls, + usageSnapshot: newBalance !== null ? { remainingCredits: newBalance } : null, + groundingMetadata: result.groundingMetadata, + }) +``` + +The surrounding outer `try { ... } catch (err) { console.error('agent/run error:', err); ...; res.status(500)... }` stays exactly as-is — it already handles any error thrown by `runAgentFn` (including the `consumeAgentEvents`-internal refund-then-rethrow) by logging and returning 500. + +- [ ] **Step 8: Remove the four now-obsolete route-level credit tests from `index.test.ts`** + +In `cloud-agent/src/index.test.ts`, delete these four tests entirely (they tested route-level pre-spend/refund behavior that no longer exists — that responsibility moved into `consumeAgentEvents`, already covered by Task 4 Step 1's dedicated tests): +- `test('POST /agent/run returns 402 when spendCredit throws INSUFFICIENT_CREDITS', ...)` (originally lines 322-337) +- `test('POST /agent/run calls refundCredit and returns 500 when runAgentFn throws', ...)` (originally lines 339-357) +- `test('POST /agent/run swallows refundCredit failure and still returns 500 with ADK error', ...)` (originally lines 359-377) +- `test('POST /agent/run does not call runAgentFn when spendCredit throws INSUFFICIENT_CREDITS', ...)` (originally lines 406-424) + +Keep `'POST /agent/run returns usageSnapshot.remainingCredits on success'` and `'POST /agent/run returns usageSnapshot: null and 200 when getBalance throws'` — both are still route-level behavior (`cs.getBalance` is still called at the end of the handler) and need no changes. + +- [ ] **Step 9: Update the shared `mockCreditService` type in `index.test.ts`** + +Lines 84-88: +```typescript +const mockCreditService = { + spendCredit: async (_userId: string): Promise => 'mock-txid', + refundCredit: async (_userId: string, _txId: string): Promise => {}, + getBalance: async (_userId: string): Promise => 42, +} +``` +→ +```typescript +const mockCreditService = { + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 1 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, + getBalance: async (_userId: string): Promise => 42, +} +``` + +- [ ] **Step 10: Typecheck and run the full cloud-agent suite** + +Run: `cd cloud-agent && npm run typecheck` +Expected: no errors. If `mockRunAgent`/`failingAgent` in `index.test.ts` (lines 79, 285, 346, 367) fail to typecheck because they don't return `groundingMetadata`, that's pre-existing (the return type already marks it optional) — no change needed there. + +Run: `cd cloud-agent && npm run lint` +Expected: no errors (watch for the removed `hasGroundingData`/`isFinalResponse` imports — if `eslint` flags anything else unused after the route-handler simplification, e.g. an unused `preAgentErr`/`adkErr` catch binding from the removed try/catch wrappers, remove it). + +Run: `cd cloud-agent && npm test 2>&1 | tail -80` +Expected: full suite passes, including `index.test.ts`, `agentEventLoop.test.ts`, and everything touched in Tasks 1-3. + +- [ ] **Step 11: Commit** + +```bash +cd cloud-agent +git add src/index.ts src/index.test.ts +git commit -m "feat(cloud-agent): bill agent turns per internal loop iteration instead of a flat 1/turn" +``` + +--- + +## Task 5: Client — raise the live-voice connect gate to 5 + +**Files:** +- Modify: `src/hooks/useLiveVoiceChat.ts` +- Modify: `__tests__/useLiveVoiceChat.test.tsx` + +- [ ] **Step 1: Write a failing boundary test** + +In `__tests__/useLiveVoiceChat.test.tsx`, the existing test `'startCall shows alert if insufficient credits'` (lines 108-126) mocks `remainingCredits: 1` to trigger the alert. Add a new test immediately after it (after the closing `})` at line 126), following the exact same structure with `remainingCredits: 4` — the new boundary value that must still be rejected under the raised gate: + +```typescript + test('startCall shows alert if credits are below the new gate of 5', async () => { + mockUseCharacter.mockReturnValue({ data: { id: 'char1', voice: 'en-US', save_to_cloud: 1 } }) + mockUseCurrentPlan.mockReturnValue({ remainingCredits: 4 }) + + let hookRef: ReturnType | null = null + await act(async () => { + create( { hookRef = h }} />) + }) + + await act(async () => { + await hookRef!.startCall() + }) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Insufficient Credits', + expect.any(String), + expect.any(Array), + ) + }) +``` + +- [ ] **Step 2: Run to verify it currently passes (gate is still 2, so 4 already exceeds it — this test won't be red yet)** + +Run: `npm test -- __tests__/useLiveVoiceChat.test.tsx -t "below the new gate"` + +This will pass immediately since 4 ≥ 2 (today's gate) does NOT trigger the alert today — meaning this test as constructed actually expects the alert but the current code with gate=2 would NOT show it for remainingCredits=4. **Correction:** since `4 >= 2`, the *current* code does NOT alert at 4 credits, so this test is genuinely red against today's code (call would proceed instead of showing the alert). Confirm failure output shows `Alert.alert` was not called, or the call proceeded when it shouldn't have. + +- [ ] **Step 3: Update `MIN_CREDITS_FOR_CALL`** + +In `src/hooks/useLiveVoiceChat.ts`, line 31: +```typescript +const MIN_CREDITS_FOR_CALL = 2 +``` +→ +```typescript +const MIN_CREDITS_FOR_CALL = 5 +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `npm test -- __tests__/useLiveVoiceChat.test.tsx` +Expected: full file passes, including the new boundary test and the existing `remainingCredits: 1` test (still correctly rejected) and all `remainingCredits: 10`/`8`/`9` success-path tests (still comfortably above 5, unaffected). + +- [ ] **Step 5: Commit** + +```bash +git add src/hooks/useLiveVoiceChat.ts __tests__/useLiveVoiceChat.test.tsx +git commit -m "feat: raise live-voice client connect gate to 5 credits, matching the server" +``` + +--- + +## Task 6: Bump `convertDocumentText` and `generateImage` to 2 credits + +**Files:** +- Modify: `functions/src/convertDocumentText.ts` +- Modify: `functions/src/generateImage.ts` +- Modify: `functions/src/generateImage.test.ts` + +(`convertDocumentText.test.ts` needs no changes — it never asserts the `amount` argument passed to `spendCredits`, only that spend/refund happen at all.) + +- [ ] **Step 1: Write failing assertions in `generateImage.test.ts`** + +Update the three existing tests that assert credit amounts: + +Lines 135-138 (`'generateImageHandler spends one credit for payg users'` — rename to reflect the new cost): +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 1); + return [{ transactionId: 'mock-tx-id', amount: 1 }]; + }; +``` +→ +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 2); + return [{ transactionId: 'mock-tx-id', amount: 2 }]; + }; +``` +And line 160: `assert.equal(result.creditsSpent, 1);` → `assert.equal(result.creditsSpent, 2);` +Rename the test at line 126: `"generateImageHandler spends one credit for payg users"` → `"generateImageHandler spends two credits for payg users"` + +Lines 179-188 (`'generateImageHandler rejects unsupported mime type from model and refunds credit'`): +```typescript + creditService.spendCredits = async () => { + spendCalls += 1; + return [{ transactionId: 'mock-tx-id', amount: 1 }]; + }; + creditService.refundCredit = async (userId, allocations) => { + assert.equal(userId, user.id); + assert.deepEqual(allocations, [{ transactionId: 'mock-tx-id', amount: 1 }]); + refundCalls += 1; + }; +``` +→ +```typescript + creditService.spendCredits = async () => { + spendCalls += 1; + return [{ transactionId: 'mock-tx-id', amount: 2 }]; + }; + creditService.refundCredit = async (userId, allocations) => { + assert.equal(userId, user.id); + assert.deepEqual(allocations, [{ transactionId: 'mock-tx-id', amount: 2 }]); + refundCalls += 1; + }; +``` + +Lines 261-266 and 284 (`'generateImageHandler allows cancelled plans to spend remaining credits'`): +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 1); + return [{ transactionId: 'mock-tx-id', amount: 1 }]; + }; +``` +→ +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 2); + return [{ transactionId: 'mock-tx-id', amount: 2 }]; + }; +``` +And line 284: `assert.equal(result.creditsSpent, 1);` → `assert.equal(result.creditsSpent, 2);` + +- [ ] **Step 2: Run to verify failures** + +Run: `cd functions && npm run build && NODE_ENV=test node --test --test-reporter spec "lib/generateImage.test.js"` +Expected: the three updated tests fail (`amount`/`creditsSpent` assertions expect 2, source still spends/returns 1). + +- [ ] **Step 3: Bump the source cost constants** + +In `functions/src/generateImage.ts`, line 127: +```typescript + const spendAllocations = await credits.spendCredits(userId, 1); +``` +→ +```typescript + const spendAllocations = await credits.spendCredits(userId, 2); +``` + +Line 351 (inside the `logger.info` call): `creditsSpent: 1,` → `creditsSpent: 2,` +Line 366 (in the returned response object): `creditsSpent: 1,` → `creditsSpent: 2,` + +In `functions/src/convertDocumentText.ts`, line 186-187: +```typescript + // 4. Charge 1 credit before conversion; refunded on any failure below. + const spendAllocations = await deps.creditService.spendCredits(user.id, 1); +``` +→ +```typescript + // 4. Charge 2 credits before conversion; refunded on any failure below. + const spendAllocations = await deps.creditService.spendCredits(user.id, 2); +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd functions && npm run build && NODE_ENV=test node --test --test-reporter spec "lib/generateImage.test.js" "lib/convertDocumentText.test.js"` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +cd functions +git add src/generateImage.ts src/generateImage.test.ts src/convertDocumentText.ts +git commit -m "feat(functions): bump convertDocumentText and generateImage to 2 credits" +``` + +--- + +## Task 7: Bill `summarizeText` (currently free) + +**Why bigger than a one-line change:** `summarizeText.ts` has no user-DB-resolution step at all today — it never calls `userRepository.getOrCreateUserByFirebaseIdentity`, because it never needed a DB `user.id` (credits are keyed by the internal DB UUID, not the Firebase UID). Billing requires adding that resolution step first, matching the exact pattern already used in `convertDocumentText.ts`. + +**Files:** +- Modify: `functions/src/summarizeText.ts` +- Modify: `functions/src/summarizeText.test.ts` + +- [ ] **Step 1: Write failing tests for billing behavior** + +Replace the full contents of `functions/src/summarizeText.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import test from "node:test"; +import {HttpsError} from "firebase-functions/v2/https"; + +import {summarizeTextHandler} from "./summarizeText.js"; +import type {CreditSpendAllocation} from "./services/creditService.js"; + +let authCounter = 0; + +function buildAuth() { + authCounter += 1; + const uid = `firebase-uid-${authCounter}`; + return { + uid, + token: { + uid, + email: `person-${authCounter}@example.com`, + }, + }; +} + +function makeOptions(overrides: { + generateSummary?: (prompt: string) => Promise; + spendCreditsImpl?: (userId: string, amount: number) => Promise; + refundCreditImpl?: (userId: string, allocations: CreditSpendAllocation[]) => Promise; +} = {}) { + return { + generateSummary: overrides.generateSummary ?? (async () => "mock summary"), + userRepository: { + getOrCreateUserByFirebaseIdentity: async () => ({ + id: "user-1", + firebaseUid: "firebase-uid-1", + email: "test@example.com", + displayName: null, + avatarUrl: null, + isProfilePublic: false, + defaultCharacterId: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }), + }, + creditService: { + spendCredits: overrides.spendCreditsImpl ?? (async () => [{transactionId: "mock-tx-id", amount: 1}]), + refundCredit: overrides.refundCreditImpl ?? (async () => {}), + }, + }; +} + +test("summarizeTextHandler rejects unauthenticated calls", async () => { + await assert.rejects( + async () => summarizeTextHandler({auth: null, data: {text: "hello", maxCharacters: 100}} as never), + (err: unknown) => err instanceof HttpsError && err.code === "unauthenticated" + ); +}); + +test("summarizeTextHandler validates input payload", async () => { + const auth = buildAuth(); + + await assert.rejects( + async () => + summarizeTextHandler( + { + auth, + data: { + text: " ", + maxCharacters: 200, + }, + } as never, + makeOptions({generateSummary: async () => "unused"}), + ), + (err: unknown) => err instanceof HttpsError && err.code === "invalid-argument" + ); +}); + +test("summarizeTextHandler trims and truncates generated summary", async () => { + const auth = buildAuth(); + + const result = await summarizeTextHandler( + { + auth, + data: { + text: "Long conversation transcript", + maxCharacters: 12, + }, + } as never, + makeOptions({generateSummary: async () => " 0123456789ABCDEF "}), + ); + + assert.equal(result.summary, "0123456789AB"); +}); + +test("summarizeTextHandler spends 1 credit before summarizing", async () => { + const auth = buildAuth(); + let spentAmount: number | null = null; + + const result = await summarizeTextHandler( + { + auth, + data: {text: "hello", maxCharacters: 50}, + } as never, + makeOptions({ + spendCreditsImpl: async (_userId, amount) => { + spentAmount = amount; + return [{transactionId: "mock-tx-id", amount: 1}]; + }, + }), + ); + + assert.equal(spentAmount, 1); + assert.equal(result.summary, "mock summary"); +}); + +test("summarizeTextHandler rejects when credits are insufficient", async () => { + const auth = buildAuth(); + + await assert.rejects( + async () => + summarizeTextHandler( + {auth, data: {text: "hello", maxCharacters: 50}} as never, + makeOptions({spendCreditsImpl: async () => null}), + ), + (err: unknown) => err instanceof HttpsError && err.code === "failed-precondition" + ); +}); + +test("summarizeTextHandler refunds the credit when the model call fails", async () => { + const auth = buildAuth(); + let refunded = false; + + await assert.rejects( + async () => + summarizeTextHandler( + {auth, data: {text: "hello", maxCharacters: 50}} as never, + makeOptions({ + generateSummary: async () => { throw new Error("Vertex AI unavailable"); }, + refundCreditImpl: async () => { refunded = true; }, + }), + ), + ); + + assert.equal(refunded, true); +}); + +test("summarizeTextHandler refunds the credit when the model returns an empty summary", async () => { + const auth = buildAuth(); + let refunded = false; + + await assert.rejects( + async () => + summarizeTextHandler( + {auth, data: {text: "hello", maxCharacters: 50}} as never, + makeOptions({ + generateSummary: async () => " ", + refundCreditImpl: async () => { refunded = true; }, + }), + ), + (err: unknown) => err instanceof HttpsError && err.code === "internal" + ); + + assert.equal(refunded, true); +}); +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `cd functions && npm run build 2>&1 | head -40` +Expected: TypeScript errors — `summarizeTextHandler` doesn't accept a second argument with `userRepository`/`creditService` fields yet (its `SummarizeTextOptions` only has `generateSummary`). + +- [ ] **Step 3: Add user resolution and billing to the handler** + +In `functions/src/summarizeText.ts`, add imports after the existing ones (after line 4): +```typescript +import { userRepository } from "./services/userRepository.js"; +import { creditService } from "./services/creditService.js"; +``` + +Add a cost constant after `MAX_OUTPUT_TOKENS` (line 12): +```typescript +const SUMMARIZE_TEXT_COST = 1; +``` + +Update `SummarizeTextOptions` (lines 25-27): +```typescript +interface SummarizeTextOptions { + generateSummary?: GenerateSummaryFn; +} +``` +→ +```typescript +interface SummarizeTextOptions { + generateSummary?: GenerateSummaryFn; + userRepository?: Pick; + creditService?: Pick; +} +``` + +Replace the handler body (lines 138-171): +```typescript +const handler = async ( + request: CallableRequest, + options: SummarizeTextOptions = {} +): Promise => { + if (!request.auth) { + throw new HttpsError("unauthenticated", "Authentication required."); + } + + const decoded: DecodedIdToken = request.auth.token as DecodedIdToken; + if (!decoded || decoded.uid !== request.auth.uid) { + throw new HttpsError("unauthenticated", "Invalid Firebase authentication token."); + } + + const {text, maxCharacters} = parseInput(request.data); + const users = options.userRepository ?? userRepository; + const credits = options.creditService ?? creditService; + + const user = await users.getOrCreateUserByFirebaseIdentity({ + firebaseUid: request.auth.uid, + email: typeof decoded.email === "string" ? decoded.email.trim() : "", + displayName: decoded.name, + }); + + const spendAllocations = await credits.spendCredits(user.id, SUMMARIZE_TEXT_COST); + if (!spendAllocations) { + throw new HttpsError("failed-precondition", "Insufficient credits to summarize text."); + } + + const generateSummary = options.generateSummary ?? getSummaryGenerator(); + + let summary: string; + try { + summary = await generateSummary(buildPrompt(text, maxCharacters)); + } catch (error) { + logger.error("summarizeText model call failed", {error}); + try { + await credits.refundCredit(user.id, spendAllocations); + } catch (refundError) { + logger.error("Failed to refund credits after summarizeText failure", {userId: user.id, error: refundError}); + } + if (error instanceof HttpsError) { + throw error; + } + throw new HttpsError("internal", "Failed to summarize text."); + } + + const normalizedSummary = truncateSummary(summary, maxCharacters); + if (!normalizedSummary) { + try { + await credits.refundCredit(user.id, spendAllocations); + } catch (refundError) { + logger.error("Failed to refund credits after empty summarizeText result", {userId: user.id, error: refundError}); + } + throw new HttpsError("internal", "Model returned an empty summary."); + } + + return {summary: normalizedSummary}; +}; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd functions && npm run build && NODE_ENV=test node --test --test-reporter spec "lib/summarizeText.test.js"` +Expected: all 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd functions +git add src/summarizeText.ts src/summarizeText.test.ts +git commit -m "feat(functions): bill summarizeText 1 credit/call (was unbilled)" +``` + +--- + +## Task 8: Bill `generateEmbedding` (currently free) with the `Math.ceil` formula + +**Files:** +- Modify: `functions/src/generateEmbedding.ts` +- Modify: `functions/src/generateEmbedding.test.ts` + +- [ ] **Step 1: Rewrite the test file with a `makeOptions` helper and billing tests** + +Replace the full contents of `functions/src/generateEmbedding.test.ts`: + +```typescript +import assert from "node:assert/strict"; +import test from "node:test"; +import {HttpsError, CallableRequest} from "firebase-functions/v2/https"; +import {generateEmbeddingHandler} from "./generateEmbedding.js"; +import type {CreditSpendAllocation} from "./services/creditService.js"; + +let counter = 0; +function buildAuth() { + counter += 1; + const uid = `uid-${counter}`; + return { uid, token: { uid, email: `user-${counter}@example.com` } }; +} + +const MOCK_EMBEDDING = Array.from({ length: 768 }, (_, i) => i / 768); +const mockEmbedder = async (_text: string, _taskType: string) => MOCK_EMBEDDING; + +function makeOptions(overrides: { + embedder?: (text: string, taskType: string) => Promise; + spendCreditsImpl?: (userId: string, amount: number) => Promise; + refundCreditImpl?: (userId: string, allocations: CreditSpendAllocation[]) => Promise; +} = {}) { + return { + embedder: overrides.embedder ?? mockEmbedder, + userRepository: { + getOrCreateUserByFirebaseIdentity: async () => ({ + id: "user-1", + firebaseUid: "firebase-uid-1", + email: "test@example.com", + displayName: null, + avatarUrl: null, + isProfilePublic: false, + defaultCharacterId: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }), + }, + creditService: { + spendCredits: overrides.spendCreditsImpl ?? (async () => [{transactionId: "mock-tx-id", amount: 1}]), + refundCredit: overrides.refundCreditImpl ?? (async () => {}), + }, + }; +} + +test("generateEmbedding: rejects unauthenticated request", async () => { + const request = { auth: null, data: { text: "hello" } }; + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "unauthenticated"); + return true; + } + ); +}); + +test("generateEmbedding: rejects missing or invalid request data", async () => { + const auth = buildAuth(); + const invalidRequests = [ + { auth, data: null }, + { auth, data: undefined }, + { auth, data: "not-an-object" }, + { auth, data: 123 }, + { auth, data: [] }, + ] as Array<{ auth: unknown; data: unknown }>; + + for (const request of invalidRequests) { + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "invalid-argument"); + assert.match(err.message, /Request data must be an object/i); + return true; + } + ); + } +}); + +test("generateEmbedding: rejects empty text", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "" } }; + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "invalid-argument"); + assert.match(err.message, /text/i); + return true; + } + ); +}); + +test("generateEmbedding: rejects whitespace-only text", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: " " } }; + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "invalid-argument"); + assert.match(err.message, /text/i); + return true; + } + ); +}); + +test("generateEmbedding: rejects text over max length", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "x".repeat(8_001) } }; + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "invalid-argument"); + assert.match(err.message, /8000/); + return true; + } + ); +}); + +test("generateEmbedding: accepts text of exactly max length", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "x".repeat(8_000) } }; + const result = await generateEmbeddingHandler( + request as unknown as CallableRequest, + makeOptions() + ); + assert.deepEqual(result.embedding, MOCK_EMBEDDING); +}); + +test("generateEmbedding: rejects invalid taskType", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "hello", taskType: "INVALID_TYPE" } }; + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "invalid-argument"); + assert.match(err.message, /taskType/); + return true; + } + ); +}); + +test("generateEmbedding: returns embedding for valid request", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "Tell me about dragons." } }; + const result = await generateEmbeddingHandler( + request as unknown as CallableRequest, + makeOptions() + ); + assert.deepEqual(result.embedding, MOCK_EMBEDDING); +}); + +test("generateEmbedding: passes taskType to embedder", async () => { + const auth = buildAuth(); + const capturedArgs: { text: string; taskType: string }[] = []; + const trackingEmbedder = async (text: string, taskType: string) => { + capturedArgs.push({ text, taskType }); + return MOCK_EMBEDDING; + }; + + const request = { auth, data: { text: "hello", taskType: "RETRIEVAL_QUERY" } }; + await generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions({ embedder: trackingEmbedder })); + assert.equal(capturedArgs.length, 1); + assert.equal(capturedArgs[0].taskType, "RETRIEVAL_QUERY"); +}); + +test("generateEmbedding: defaults taskType to RETRIEVAL_DOCUMENT", async () => { + const auth = buildAuth(); + const capturedArgs: { text: string; taskType: string }[] = []; + const trackingEmbedder = async (text: string, taskType: string) => { + capturedArgs.push({ text, taskType }); + return MOCK_EMBEDDING; + }; + + const request = { auth, data: { text: "hello" } }; + await generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions({ embedder: trackingEmbedder })); + assert.equal(capturedArgs[0].taskType, "RETRIEVAL_DOCUMENT"); +}); + +test("generateEmbedding: wraps embedder errors as HttpsError internal", async () => { + const auth = buildAuth(); + const failingEmbedder = async (_text: string, _taskType: string): Promise => { + throw new Error("Vertex AI exploded"); + }; + const request = { auth, data: { text: "hello" } }; + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions({ embedder: failingEmbedder })), + (err: HttpsError) => { + assert.equal(err.code, "internal"); + return true; + } + ); +}); + +test("generateEmbedding: throttles a single user after too many requests within the window", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "hello" } }; + + // Throttle limit is 20 requests/minute per user. + for (let i = 0; i < 20; i++) { + await generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()); + } + + await assert.rejects( + () => generateEmbeddingHandler(request as unknown as CallableRequest, makeOptions()), + (err: HttpsError) => { + assert.equal(err.code, "resource-exhausted"); + return true; + } + ); +}); + +test("generateEmbedding: does not throttle a different user", async () => { + const throttledAuth = buildAuth(); + const throttledRequest = { auth: throttledAuth, data: { text: "hello" } }; + for (let i = 0; i < 20; i++) { + await generateEmbeddingHandler(throttledRequest as unknown as CallableRequest, makeOptions()); + } + + const otherAuth = buildAuth(); + const otherRequest = { auth: otherAuth, data: { text: "hello" } }; + const result = await generateEmbeddingHandler(otherRequest as unknown as CallableRequest, makeOptions()); + assert.deepEqual(result.embedding, MOCK_EMBEDDING); +}); + +// ── Billing ────────────────────────────────────────────────────────────────── + +test("generateEmbedding: spends 1 credit for a request under 50,000 characters", async () => { + const auth = buildAuth(); + let spentAmount: number | null = null; + const request = { auth, data: { text: "hello" } }; + + await generateEmbeddingHandler( + request as unknown as CallableRequest, + makeOptions({ + spendCreditsImpl: async (_userId, amount) => { + spentAmount = amount; + return [{ transactionId: "mock-tx-id", amount }]; + }, + }), + ); + + assert.equal(spentAmount, 1); +}); + +test("generateEmbedding: rejects when credits are insufficient", async () => { + const auth = buildAuth(); + const request = { auth, data: { text: "hello" } }; + + await assert.rejects( + () => + generateEmbeddingHandler( + request as unknown as CallableRequest, + makeOptions({ spendCreditsImpl: async () => null }), + ), + (err: HttpsError) => { + assert.equal(err.code, "failed-precondition"); + return true; + } + ); +}); + +test("generateEmbedding: refunds the credit when the embedder fails", async () => { + const auth = buildAuth(); + let refunded = false; + const request = { auth, data: { text: "hello" } }; + + await assert.rejects(() => + generateEmbeddingHandler( + request as unknown as CallableRequest, + makeOptions({ + embedder: async () => { throw new Error("Vertex AI exploded"); }, + refundCreditImpl: async () => { refunded = true; }, + }), + ), + ); + + assert.equal(refunded, true); +}); +``` + +- [ ] **Step 2: Run to verify failures** + +Run: `cd functions && npm run build 2>&1 | head -40` +Expected: TypeScript errors — `generateEmbeddingHandler`'s `EmbeddingOptions` doesn't have `userRepository`/`creditService` fields yet. + +- [ ] **Step 3: Add user resolution and billing to the handler** + +In `functions/src/generateEmbedding.ts`, add imports after the existing ones (after line 4): +```typescript +import type {DecodedIdToken} from "firebase-admin/auth"; +import { userRepository } from "./services/userRepository.js"; +import { creditService } from "./services/creditService.js"; +``` + +Add a constant after `MAX_TEXT_LENGTH` (line 8): +```typescript +const EMBEDDING_CHARS_PER_CREDIT = 50_000; +``` + +Update `EmbeddingOptions` (lines 72-74): +```typescript +export interface EmbeddingOptions { + embedder?: (text: string, taskType: string) => Promise; +} +``` +→ +```typescript +export interface EmbeddingOptions { + embedder?: (text: string, taskType: string) => Promise; + userRepository?: Pick; + creditService?: Pick; +} +``` + +Replace the tail of `generateEmbeddingHandler`, from the `taskType` resolution through the return (lines 146-174): +```typescript + let taskType: GenerateEmbeddingTaskType = "RETRIEVAL_DOCUMENT"; + if (rawTaskType !== undefined && rawTaskType !== null) { + if (typeof rawTaskType !== "string") { + throw new HttpsError("invalid-argument", "taskType must be a string."); + } + if (!ALLOWED_TASK_TYPES.has(rawTaskType as GenerateEmbeddingTaskType)) { + throw new HttpsError( + "invalid-argument", + `taskType must be one of: ${[...ALLOWED_TASK_TYPES].join(", ")}.` + ); + } + taskType = rawTaskType as GenerateEmbeddingTaskType; + } + + const embedder = options.embedder ?? defaultEmbedder; + let embedding: number[]; + try { + embedding = await embedder(text.trim(), taskType); + } catch (error) { + logger.error("generateEmbedding: embedder failed", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + if (error instanceof HttpsError) throw error; + throw new HttpsError("internal", "Failed to generate embedding."); + } + + return { embedding }; +}; +``` +→ +```typescript + let taskType: GenerateEmbeddingTaskType = "RETRIEVAL_DOCUMENT"; + if (rawTaskType !== undefined && rawTaskType !== null) { + if (typeof rawTaskType !== "string") { + throw new HttpsError("invalid-argument", "taskType must be a string."); + } + if (!ALLOWED_TASK_TYPES.has(rawTaskType as GenerateEmbeddingTaskType)) { + throw new HttpsError( + "invalid-argument", + `taskType must be one of: ${[...ALLOWED_TASK_TYPES].join(", ")}.` + ); + } + taskType = rawTaskType as GenerateEmbeddingTaskType; + } + + const trimmedText = text.trim(); + const users = options.userRepository ?? userRepository; + const credits = options.creditService ?? creditService; + const decoded = request.auth.token as DecodedIdToken; + + const user = await users.getOrCreateUserByFirebaseIdentity({ + firebaseUid: request.auth.uid, + email: typeof decoded.email === "string" ? decoded.email.trim() : "", + displayName: decoded.name, + }); + + const cost = Math.ceil(trimmedText.length / EMBEDDING_CHARS_PER_CREDIT); + const spendAllocations = await credits.spendCredits(user.id, cost); + if (!spendAllocations) { + throw new HttpsError("failed-precondition", "Insufficient credits to generate embedding."); + } + + const embedder = options.embedder ?? defaultEmbedder; + let embedding: number[]; + try { + embedding = await embedder(trimmedText, taskType); + } catch (error) { + logger.error("generateEmbedding: embedder failed", { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + try { + await credits.refundCredit(user.id, spendAllocations); + } catch (refundError) { + logger.error("Failed to refund credits after generateEmbedding failure", {userId: user.id, error: refundError}); + } + if (error instanceof HttpsError) throw error; + throw new HttpsError("internal", "Failed to generate embedding."); + } + + return { embedding }; +}; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `cd functions && npm run build && NODE_ENV=test node --test --test-reporter spec "lib/generateEmbedding.test.js"` +Expected: all 17 tests pass. + +- [ ] **Step 5: Commit** + +```bash +cd functions +git add src/generateEmbedding.ts src/generateEmbedding.test.ts +git commit -m "feat(functions): bill generateEmbedding Math.ceil(chars/50000) credits (was unbilled)" +``` + +--- + +## Task 9: Split `generateReply` into grounded (3) / standard (1) + +**Files:** +- Modify: `functions/src/generateReply.ts` +- Modify: `functions/src/generateReply.test.ts` + +- [ ] **Step 1: Update the 5 existing tests that assert the old flat cost** + +`buildStructuredRequestData(...)` (the test file's shared helper) never sets a `tools` field — every test using it exercises the **grounded** default path, which now costs 3, not 1. Apply these four edits: + +**Edit A** — in `test("generateReplyHandler allows intro requests with structured payload to proceed", ...)`, replace: +```typescript + creditService.spendCredits = async () => [{ transactionId: 'mock-tx-id', amount: 1 }]; + creditService.getCredits = async () => 2; + + let generateTextCalled = false; +``` +with: +```typescript + creditService.spendCredits = async (_userId, amount) => { + assert.equal(amount, 3); + return [{ transactionId: 'mock-tx-id', amount: 3 }]; + }; + creditService.getCredits = async () => 2; + + let generateTextCalled = false; +``` +and further down in the same test, replace `assert.equal(result.creditsSpent, 1);` with `assert.equal(result.creditsSpent, 3);`. + +**Edit B** — rename `test("generateReplyHandler spends one credit for payg users", ...)` to `test("generateReplyHandler spends three credits for payg users on the grounded default path", ...)`, and inside it replace: +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 1); + return [{ transactionId: 'mock-tx-id', amount: 1 }]; + }; +``` +with: +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 3); + return [{ transactionId: 'mock-tx-id', amount: 3 }]; + }; +``` +and later in the same test replace `assert.equal(result.creditsSpent, 1);` with `assert.equal(result.creditsSpent, 3);`. + +**Edit C** — in `test("generateReplyHandler allows cancelled plans to spend remaining credits", ...)`, replace: +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 1); + return [{ transactionId: 'mock-tx-id', amount: 1 }]; + }; +``` +with: +```typescript + creditService.spendCredits = async (_userId, amount) => { + spendCalls += 1; + assert.equal(amount, 3); + return [{ transactionId: 'mock-tx-id', amount: 3 }]; + }; +``` +and replace `assert.equal(result.creditsSpent, 1);` with `assert.equal(result.creditsSpent, 3);` later in the same test. + +**Edit D** — in `test("generateReplyHandler does not bootstrap a subscription in the new credit flow", ...)`, replace: +```typescript + creditService.spendCredits = async () => [{ transactionId: 'mock-tx-id', amount: 1 }]; + creditService.getCredits = async () => 49; +``` +with: +```typescript + creditService.spendCredits = async () => [{ transactionId: 'mock-tx-id', amount: 3 }]; + creditService.getCredits = async () => 49; +``` +and replace `assert.equal(result.creditsSpent, 1);` with `assert.equal(result.creditsSpent, 3);` in the same test. + +**Edit E** — in `test("generateReplyHandler still returns reply when unsyncedHistory DB insert fails", ...)`, replace: +```typescript + creditService.spendCredits = async () => [{ transactionId: 'mock-tx-id', amount: 1 }]; + creditService.getCredits = async () => 4; +``` +with: +```typescript + creditService.spendCredits = async () => [{ transactionId: 'mock-tx-id', amount: 3 }]; + creditService.getCredits = async () => 4; +``` +and replace `assert.equal(result.creditsSpent, 1);` with `assert.equal(result.creditsSpent, 3);` (the one at line ~1125, following `assert.equal(result.reply, "reply despite db failure");`). + +Leave `test("generateReplyHandler returns functionCalls instead of throwing on an empty text response", ...)` (the one asserting `result.creditsSpent === 1` at line 626) **unchanged** — it explicitly passes `tools: [{ name: 'get_current_time', ... }]`, so it's the standard path and correctly stays at 1. + +- [ ] **Step 2: Add a new explicit grounded-vs-standard cost test** + +Add this test near the functionCalls test (after it, for locality): + +```typescript +test("generateReplyHandler charges 3 credits when no tools are supplied (grounded default)", async () => { + const auth = buildAuth(); + + await withServiceMocks(async () => { + const user = buildUser(auth); + let capturedAmount: number | null = null; + + userRepository.getOrCreateUserByFirebaseIdentity = async () => user; + subscriptionService.getSubscription = async () => buildSubscription(user.id, "payg", 3); + creditService.spendCredits = async (_userId, amount) => { + capturedAmount = amount; + return [{ transactionId: 'mock-tx-id', amount }]; + }; + creditService.getCredits = async () => 2; + + const result = await generateReplyHandler( + { + auth, + data: buildStructuredRequestData('hello'), + } as never, + { + generateText: async () => ({ text: "grounded reply" }), + } + ); + + assert.equal(capturedAmount, 3); + assert.equal(result.creditsSpent, 3); + }); +}); + +test("generateReplyHandler charges 1 credit when explicit tools are supplied (standard)", async () => { + const auth = buildAuth(); + + await withServiceMocks(async () => { + const user = buildUser(auth); + let capturedAmount: number | null = null; + + userRepository.getOrCreateUserByFirebaseIdentity = async () => user; + subscriptionService.getSubscription = async () => buildSubscription(user.id, "payg", 3); + creditService.spendCredits = async (_userId, amount) => { + capturedAmount = amount; + return [{ transactionId: 'mock-tx-id', amount }]; + }; + creditService.getCredits = async () => 2; + + const result = await generateReplyHandler( + { + auth, + data: { + contents: [{ role: 'user', parts: [{ text: 'what time is it' }] }], + systemInstruction: 'You are a helpful assistant.', + tools: [{ name: 'get_current_time', description: 'Get the time', parameters: { type: 'object', properties: {} } }], + }, + } as never, + { + generateText: async () => ({ text: "standard reply" }), + } + ); + + assert.equal(capturedAmount, 1); + assert.equal(result.creditsSpent, 1); + }); +}); +``` + +- [ ] **Step 3: Run to verify failures** + +Run: `cd functions && npm run build && NODE_ENV=test node --test --test-reporter spec "lib/generateReply.test.js" 2>&1 | tail -60` +Expected: the 5 updated tests and 2 new tests fail — `chargeForReply` still hardcodes `1`, ignoring `tools`. + +- [ ] **Step 4: Thread `tools`-derived cost through `chargeForReply`** + +In `functions/src/generateReply.ts`, replace `chargeForReply` (lines 535-546): +```typescript +async function chargeForReply( + userId: string, + credits: Pick +): Promise<{ spendAllocations: CreditSpendAllocation[]; remainingCredits: number }> { + const spendAllocations = await credits.spendCredits(userId, 1); + if (spendAllocations === null) { + throw new HttpsError("failed-precondition", "Insufficient credits."); + } + + const remainingCredits = await credits.getCredits(userId); + return { spendAllocations, remainingCredits }; +} +``` +with: +```typescript +function computeReplyCost(tools?: ToolDeclaration[]): number { + return tools && tools.length > 0 ? 1 : 3; +} + +async function chargeForReply( + userId: string, + credits: Pick, + cost: number +): Promise<{ spendAllocations: CreditSpendAllocation[]; remainingCredits: number }> { + const spendAllocations = await credits.spendCredits(userId, cost); + if (spendAllocations === null) { + throw new HttpsError("failed-precondition", "Insufficient credits."); + } + + const remainingCredits = await credits.getCredits(userId); + return { spendAllocations, remainingCredits }; +} +``` + +Update the call site (lines 649-652): +```typescript + try { + const charge = await chargeForReply(user.id, credits); + spendAllocations = charge.spendAllocations; + remainingCredits = charge.remainingCredits; +``` +with: +```typescript + const cost = computeReplyCost(tools); + + try { + const charge = await chargeForReply(user.id, credits, cost); + spendAllocations = charge.spendAllocations; + remainingCredits = charge.remainingCredits; +``` + +Update the two hardcoded response literals — line 670 (inside the `functionCalls` early-return branch): +```typescript + creditsSpent: 1, +``` +→ +```typescript + creditsSpent: cost, +``` +and line 689 (final success return): +```typescript + creditsSpent: 1, +``` +→ +```typescript + creditsSpent: cost, +``` + +(`tools` is already destructured from `parsed` at line 569, in scope at the `computeReplyCost(tools)` call site. `ToolDeclaration` is already imported/used elsewhere in this file — no new import needed.) + +- [ ] **Step 5: Run to verify pass** + +Run: `cd functions && npm run build && NODE_ENV=test node --test --test-reporter spec "lib/generateReply.test.js" 2>&1 | tail -80` +Expected: full file passes (all pre-existing tests plus the 2 new ones). + +- [ ] **Step 6: Commit** + +```bash +cd functions +git add src/generateReply.ts src/generateReply.test.ts +git commit -m "feat(functions): split generateReply into grounded (3 credits) / standard (1 credit)" +``` + +--- + +## Task 10: Update `docs/billing-and-credits.md` + +**Files:** +- Modify: `docs/billing-and-credits.md` + +- [ ] **Step 1: Rewrite the Credit Consumption table and connect-gate line** + +Replace lines 24-39 of `docs/billing-and-credits.md`: + +```markdown +### Credit Consumption + +Per-action costs. Firebase text/chat paths charge **per round-trip** (a multi-tool turn costs more); cloud-agent text turns charge **per internal tool-call loop iteration, capped at 5**. Live voice is billed separately on a 60-second timer. This difference is intentional. + +| Action | Path | Cost | Refund on failure | +|---|---|---|---| +| Text chat reply (grounded) | `generateReply` (Functions), no explicit `tools` (default googleSearch) | 3 / round-trip | Yes | +| Text chat reply (standard) | `generateReply` (Functions), explicit `tools` supplied | 1 / round-trip | Yes | +| Image generation | `generateImage` | 2 | Yes | +| Document text conversion | `convertDocumentText` | 2 | Yes | +| Summarization | `summarizeText` | 1 | Yes | +| Embeddings | `generateEmbedding` | 1 / 50,000 characters (`Math.ceil`) | Yes | +| Wiki LLM / sync, memory write/heal | `wikiLlm`, `wikiSync`, `memoryWrite`, `memoryHeal` | 1 each | Yes | +| Agent turn (text) | cloud-agent `POST /agent/run` | 1 / internal tool-call loop iteration, max 5 | Yes (only credits actually spent this turn) | +| Live voice | cloud-agent `/agent/live` | 5 / 60s timer | Partial minute not billed | +| Scheduler trigger | cloud-agent scheduler-trigger | 1 (deduped) | Yes | +| `browser_action` tool | contextual | Voice: 1; Text: pre-billed (skipped) | See Browser Action Billing | + +**Live voice connect gate:** a session requires a balance of **≥ 5** to start (enforced by both the client and the server). Billing runs on a 60-second timer, so a session shorter than the first tick is not billed. +``` + +- [ ] **Step 2: Verify the table renders correctly and matches the spec** + +Run: `grep -A2 "Live voice connect gate" docs/billing-and-credits.md` +Expected: shows the updated `≥ 5` line. Manually diff the 12 cost values in the new table against the Overview table in `docs/superpowers/specs/2026-07-01-credit-economy-repricing-design.md` — every number must match exactly. + +- [ ] **Step 3: Commit** + +```bash +git add docs/billing-and-credits.md +git commit -m "docs: update Credit Consumption table for the July 2026 repricing" +``` + +--- + +## Task 11: Update consumer-facing copy + +**Files:** +- Modify: `app/index.web.tsx` +- Modify: `src/components/LandingPage/FeaturesSection.tsx` + +- [ ] **Step 1: Update the meta description** + +In `app/index.web.tsx`, line 29: +```typescript + content="Create AI characters, chat with them, and talk in real time with live voice calls. Hands-free conversations with web search and shared memory. 1 credit per minute for live voice." +``` +→ +```typescript + content="Create AI characters, chat with them, and talk in real time with live voice calls. Hands-free conversations with web search and shared memory. 5 credits per minute for live voice." +``` + +- [ ] **Step 2: Update the landing page feature copy** + +In `src/components/LandingPage/FeaturesSection.tsx`, line 11: +```typescript + body: 'Experience natural, uninterrupted conversations that feel exactly like a real phone call. Talk hands-free on speakerphone, interrupt your character seamlessly if you change your mind, and listen as they search the web or check your shared memory mid-conversation. (Live voice sessions cost just 1 credit per minute.)', +``` +→ +```typescript + body: 'Experience natural, uninterrupted conversations that feel exactly like a real phone call. Talk hands-free on speakerphone, interrupt your character seamlessly if you change your mind, and listen as they search the web or check your shared memory mid-conversation. (Live voice sessions cost 5 credits per minute.)', +``` + +Leave `app/(drawer)/(tabs)/characters/[id]/edit.tsx:469` ("Costs 1 credit per sync.") and `app/support.tsx:92` ("Voice replies cost 2 credits per reply") untouched — the former is `wikiSync` (unchanged), the latter references the dead `generateVoiceReply` path being deleted by a separate spec. + +- [ ] **Step 3: Verify no other stale references remain** + +Run: `grep -rn "1 credit per minute\|1 credit/minute\|1 credit / 60s\|1 credit per 60" app/ src/ --include="*.tsx" --include="*.ts"` +Expected: no matches. + +- [ ] **Step 4: Commit** + +```bash +git add app/index.web.tsx src/components/LandingPage/FeaturesSection.tsx +git commit -m "docs: update consumer-facing copy to 5 credits/minute for live voice" +``` + +--- + +## Final verification (run after all 11 tasks) + +```bash +cd cloud-agent && npm run typecheck && npm run lint && npm test +cd ../functions && npm run typecheck && npm run lint && npm test +cd .. && npm run typecheck && npm run lint && npm test -- __tests__/useLiveVoiceChat.test.tsx +``` + +All three suites must pass with zero failures before considering this plan complete. From 2243a5d515b91ecfa549e1634f44e2d0b3718773 Mon Sep 17 00:00:00 2001 From: equationalapplications Date: Thu, 2 Jul 2026 08:01:45 -0400 Subject: [PATCH 4/8] feat(billing): ship July 2026 credit economy repricing Meter agent turns per internal tool-loop on HTTP and WebSocket with a zero-balance pre-flight gate, raise live voice to 5 credits/tick, port the multi-row FIFO allocator, bill summarize/embedding, and split generateReply into grounded (3) vs standard (1) pricing. Co-authored-by: Cursor --- __tests__/useLiveVoiceChat.test.tsx | 20 + app/index.web.tsx | 2 +- cloud-agent/src/agent.live.test.ts | 4 + .../handlers/schedulerTriggerHandler.test.ts | 4 +- .../src/handlers/schedulerTriggerHandler.ts | 14 +- .../src/handlers/wsAgentHandler.test.ts | 8 +- cloud-agent/src/handlers/wsAgentHandler.ts | 102 +- .../src/handlers/wsLiveAgentHandler.test.ts | 73 +- .../src/handlers/wsLiveAgentHandler.ts | 4 +- cloud-agent/src/index.test.ts | 79 +- cloud-agent/src/index.ts | 93 +- cloud-agent/src/integration.test.ts | 4 +- .../src/services/agentEventLoop.test.ts | 148 + cloud-agent/src/services/agentEventLoop.ts | 186 + .../src/services/creditService.test.ts | 119 +- cloud-agent/src/services/creditService.ts | 114 +- cloud-agent/src/tools/browserAction.test.ts | 6 +- cloud-agent/src/tools/browserAction.ts | 12 +- docs/billing-and-credits.md | 17 +- .../plans/2026-06-29-mv3-bridge-phase1.md | 3138 ----------------- ...-phase2-browser-bridge-stateful-actions.md | 2082 ----------- .../2026-06-29-phase3-proactive-scheduler.md | 1014 ------ .../2026-06-30-cws-extension-readiness.md | 512 --- .../plans/2026-07-01-billing-hardening.md | 2802 --------------- ...026-07-01-credit-economy-repricing-plan.md | 2509 ------------- .../plans/2026-07-01-credit-improvements.md | 388 -- ...-07-01-live-voice-credit-reconciliation.md | 609 ---- ...6-07-01-credit-economy-repricing-design.md | 36 +- functions/src/convertDocumentText.ts | 4 +- functions/src/generateEmbedding.test.ts | 125 +- functions/src/generateEmbedding.ts | 34 +- functions/src/generateImage.test.ts | 18 +- functions/src/generateImage.ts | 6 +- functions/src/generateReply.test.ts | 93 +- functions/src/generateReply.ts | 17 +- functions/src/summarizeText.test.ts | 108 +- functions/src/summarizeText.ts | 29 + .../LandingPage/FeaturesSection.tsx | 2 +- src/hooks/useLiveVoiceChat.ts | 2 +- 39 files changed, 1085 insertions(+), 13452 deletions(-) create mode 100644 cloud-agent/src/services/agentEventLoop.test.ts create mode 100644 cloud-agent/src/services/agentEventLoop.ts delete mode 100644 docs/superpowers/plans/2026-06-29-mv3-bridge-phase1.md delete mode 100644 docs/superpowers/plans/2026-06-29-phase2-browser-bridge-stateful-actions.md delete mode 100644 docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md delete mode 100644 docs/superpowers/plans/2026-06-30-cws-extension-readiness.md delete mode 100644 docs/superpowers/plans/2026-07-01-billing-hardening.md delete mode 100644 docs/superpowers/plans/2026-07-01-credit-economy-repricing-plan.md delete mode 100644 docs/superpowers/plans/2026-07-01-credit-improvements.md delete mode 100644 docs/superpowers/plans/2026-07-01-live-voice-credit-reconciliation.md diff --git a/__tests__/useLiveVoiceChat.test.tsx b/__tests__/useLiveVoiceChat.test.tsx index c5e5aba5..367710d4 100644 --- a/__tests__/useLiveVoiceChat.test.tsx +++ b/__tests__/useLiveVoiceChat.test.tsx @@ -125,6 +125,26 @@ describe('useLiveVoiceChat', () => { ) }) + test('startCall shows alert if credits are below the new gate of 5', async () => { + mockUseCharacter.mockReturnValue({ data: { id: 'char1', voice: 'en-US', save_to_cloud: 1 } }) + mockUseCurrentPlan.mockReturnValue({ remainingCredits: 4 }) + + let hookRef: ReturnType | null = null + await act(async () => { + create( { hookRef = h }} />) + }) + + await act(async () => { + await hookRef!.startCall() + }) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Insufficient Credits', + expect.any(String), + expect.any(Array), + ) + }) + test('startCall shows alert if save_to_cloud is disabled', async () => { mockUseCharacter.mockReturnValue({ data: { id: 'char1', voice: 'en-US', save_to_cloud: 0 } }) mockUseCurrentPlan.mockReturnValue({ remainingCredits: 10 }) diff --git a/app/index.web.tsx b/app/index.web.tsx index 8787a277..7aa7be4d 100644 --- a/app/index.web.tsx +++ b/app/index.web.tsx @@ -26,7 +26,7 @@ export default function WebIndex() { Clanker — Design, chat with, and share your own AI characters [{ transactionId: 'live-test-tx', amount: 1 }], + refundCredit: async () => {}, + }, }) assert.ok(toolCalls.includes('get_current_time'), 'expected get_current_time to be called') diff --git a/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts b/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts index 2d76a3dc..1121787b 100644 --- a/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts +++ b/cloud-agent/src/handlers/schedulerTriggerHandler.test.ts @@ -24,7 +24,7 @@ function buildApp(overrides: { sendProactive?: (token: string, sid: string, tid: string, body: string) => Promise getExpoPushToken?: (uid: string) => Promise resolveUserId?: (uid: string) => Promise - spendCredit?: () => Promise + spendCredit?: () => Promise<{ transactionId: string; amount: number }[]> refundCredit?: () => Promise abortPendingTaskIfOffline?: () => Promise writeTaskResult?: () => Promise @@ -64,7 +64,7 @@ function buildApp(overrides: { } const mockCredit = { - spendCredit: overrides.spendCredit ?? (async () => { creditCalls.spend++; return 'tx1' }), + spendCredit: overrides.spendCredit ?? (async () => { creditCalls.spend++; return [{ transactionId: 'tx1', amount: 1 }] }), refundCredit: overrides.refundCredit ?? (async () => { creditCalls.refund++ }), } diff --git a/cloud-agent/src/handlers/schedulerTriggerHandler.ts b/cloud-agent/src/handlers/schedulerTriggerHandler.ts index 8f6d520c..771e23bb 100644 --- a/cloud-agent/src/handlers/schedulerTriggerHandler.ts +++ b/cloud-agent/src/handlers/schedulerTriggerHandler.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import type { NextFunction, Request, Response } from 'express' import type { FirestoreSession } from '../services/firestoreSession.js' import type { FcmDispatcher } from '../services/fcmDispatcher.js' -import type { CreditService } from '../services/creditService.js' +import type { CreditService, CreditSpendAllocation } from '../services/creditService.js' import type { TaskDoc } from '../../../shared/dsl-types.js' import { singleActionSchema } from '../../../shared/dsl-schema.js' import { intentRequiresAuth } from '../../../shared/constants.js' @@ -181,10 +181,10 @@ export function createSchedulerTriggerHandler( } } - let txId: string | null = null + let allocations: CreditSpendAllocation[] | null = null if (!isDuplicateRun) { try { - txId = await creditService.spendCredit(userId) + allocations = await creditService.spendCredit(userId) } catch (err) { const msg = err instanceof Error ? err.message : '' if (msg === 'INSUFFICIENT_CREDITS') { @@ -212,8 +212,8 @@ export function createSchedulerTriggerHandler( await fs.writeTask(uid, activeSessionId, activeTaskId, taskIntent) } catch (err) { console.error('[scheduler-trigger] setup error:', err) - if (txId) { - try { await creditService.refundCredit(userId, txId) } catch { /* logged */ } + if (allocations) { + try { await creditService.refundCredit(userId, allocations) } catch { /* logged */ } } try { await fs.closeSession(uid, activeSessionId, 'aborted') } catch { /* ignore */ } res.status(500).json({ error: 'Internal server error' }) @@ -259,8 +259,8 @@ export function createSchedulerTriggerHandler( } catch { /* ignore */ } } - if (!isDuplicateRun && abortedOffline && txId) { - try { await creditService.refundCredit(userId, txId) } catch { /* logged */ } + if (!isDuplicateRun && abortedOffline && allocations) { + try { await creditService.refundCredit(userId, allocations) } catch { /* logged */ } } try { await fs.closeSession(uid, activeSessionId, 'aborted') } catch { /* ignore */ } diff --git a/cloud-agent/src/handlers/wsAgentHandler.test.ts b/cloud-agent/src/handlers/wsAgentHandler.test.ts index dc052d1d..df5a38dc 100644 --- a/cloud-agent/src/handlers/wsAgentHandler.test.ts +++ b/cloud-agent/src/handlers/wsAgentHandler.test.ts @@ -40,8 +40,8 @@ const mockCharacter = { const CHAR_UUID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' const mockCreditService = { - spendCredit: async (_userId: string): Promise => 'mock-txid', - refundCredit: async (_userId: string, _txId: string): Promise => {}, + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 1 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, getBalance: async (_userId: string): Promise => 42, } @@ -230,11 +230,11 @@ test('times out if auth message not sent within 5 seconds', { timeout: 10_000 }, await close() }) -test('returns INSUFFICIENT_CREDITS error when spendCredit fails', async () => { +test('returns INSUFFICIENT_CREDITS error when balance is zero before agent starts', async () => { const db = makeMockDb([[mockUser], [mockCharacter]]) const cs = { ...mockCreditService, - spendCredit: async (): Promise => { throw new Error('INSUFFICIENT_CREDITS') }, + getBalance: async () => 0, } const { server, close } = createTestWsServer({ db, diff --git a/cloud-agent/src/handlers/wsAgentHandler.ts b/cloud-agent/src/handlers/wsAgentHandler.ts index 1b55cedc..b73e0789 100644 --- a/cloud-agent/src/handlers/wsAgentHandler.ts +++ b/cloud-agent/src/handlers/wsAgentHandler.ts @@ -3,7 +3,7 @@ import type { IncomingMessage } from 'http' import admin from 'firebase-admin' import { eq, and } from 'drizzle-orm' import { z } from 'zod' -import { InMemoryRunner, isFinalResponse, createEvent, createEventActions } from '@google/adk' +import { InMemoryRunner, createEvent, createEventActions } from '@google/adk' import type { Content, GroundingMetadata } from '@google/genai' import { hasGroundingData } from '../groundingMetadata.js' import type { DrizzleClient } from '../db/client.js' @@ -13,6 +13,11 @@ import { buildAgent, assembleSystemInstruction, queryWikiContext } from '../serv import { bulkInsertUnsynced } from '../services/unsyncedHistory.js' import { createCreditService } from '../services/creditService.js' import type { CreditService } from '../services/creditService.js' +import { + assertAgentTurnCredits, + AgentInsufficientCreditsError, + consumeAgentEvents, +} from '../services/agentEventLoop.js' const contentSchema = z.object({ role: z.enum(['user', 'model']), @@ -54,7 +59,6 @@ export async function handleWsUpgrade( let authTimer: ReturnType let isCompleted = false let abortController: AbortController | null = null - let activeTxId: string | null = null let hasRun = false authTimer = setTimeout(() => { if (!userId) { @@ -67,17 +71,6 @@ export async function handleWsUpgrade( } }, AUTH_TIMEOUT_MS) - const refundIfNeeded = async () => { - if (userId && activeTxId && !isCompleted) { - try { - await cs.refundCredit(userId, activeTxId) - } catch (refundErr) { - console.error(`[CRITICAL] WS refundCredit failed user=${userId} txId=${activeTxId}`, refundErr) - } - activeTxId = null - } - } - const handleAgentRunMessage = async (data: WebSocket.RawData) => { if (!userId) { ws.send(JSON.stringify({ type: 'error', code: 'UNAUTHORIZED', message: 'Not authenticated' })) @@ -107,12 +100,10 @@ export async function handleWsUpgrade( const { message, characterId, unsyncedHistory = [], history: rawHistory = [], timezone = 'UTC' } = parseResult.data const history = rawHistory as Content[] - let txId: string try { - txId = await cs.spendCredit(userId) - } catch (creditErr: unknown) { - const msg = creditErr instanceof Error ? creditErr.message : '' - if (msg === 'INSUFFICIENT_CREDITS') { + await assertAgentTurnCredits(userId, cs) + } catch (creditErr) { + if (creditErr instanceof AgentInsufficientCreditsError) { ws.send(JSON.stringify({ type: 'error', code: 'INSUFFICIENT_CREDITS', message: 'Insufficient credits' })) ws.close(4402, 'Insufficient credits') return @@ -120,7 +111,6 @@ export async function handleWsUpgrade( throw creditErr } - activeTxId = txId isCompleted = false abortController = new AbortController() @@ -128,8 +118,6 @@ export async function handleWsUpgrade( and(eq(characters.id, characterId), eq(characters.userId, userId)), ) if (!character) { - await cs.refundCredit(userId, txId) - activeTxId = null ws.send(JSON.stringify({ type: 'error', code: 'CHARACTER_NOT_FOUND', message: 'Character not found' })) ws.close(4404, 'Character not found') return @@ -162,7 +150,6 @@ export async function handleWsUpgrade( remainingCredits: newBalance ?? 0, })) isCompleted = true - activeTxId = null ws.close(1000, 'Agent execution complete') return } @@ -172,8 +159,6 @@ export async function handleWsUpgrade( const wikiContext = await queryWikiContext(db, message, userId, characterId, embedText) systemInstruction = assembleSystemInstruction(character, wikiContext) } catch (preAgentErr) { - await cs.refundCredit(userId, txId) - activeTxId = null console.error('Failed to prepare context:', preAgentErr) ws.send(JSON.stringify({ type: 'error', code: 'INTERNAL_ERROR', message: 'Failed to prepare context' })) ws.close(1011, 'Internal error') @@ -212,58 +197,23 @@ export async function handleWsUpgrade( abortSignal: abortController.signal, }) - let lastToolName: string | null = null - let groundingMetadata: GroundingMetadata | undefined - - for await (const event of events) { - if (abortController.signal.aborted) { - throw new Error('Client disconnected') - } - if (event.errorCode || event.errorMessage) { - throw new Error(`ADK error (${event.errorCode}): ${event.errorMessage}`) - } - - if (hasGroundingData(event.groundingMetadata)) { - groundingMetadata = event.groundingMetadata - } - - if (event.content?.parts) { - for (const part of event.content.parts) { - if ('functionCall' in part) { - const fc = (part as { functionCall?: { name?: string } }).functionCall - if (fc?.name && lastToolName !== fc.name) { - ws.send(JSON.stringify({ type: 'tool_start', name: fc.name })) - lastToolName = fc.name - } - } - } - } - - if (lastToolName && event.content && !event.content.parts?.some(p => 'functionCall' in p)) { - ws.send(JSON.stringify({ type: 'tool_end', name: lastToolName })) - lastToolName = null - } - - if (event.content?.parts) { - for (const part of event.content.parts) { - if ('text' in part) { - const text = (part as { text: string }).text - if (text) { - ws.send(JSON.stringify({ type: 'token', text })) - } - } - } - } - - if (isFinalResponse(event)) { - break - } - } + const result = await consumeAgentEvents(events, userId, cs, { + shouldAbort: () => abortController!.signal.aborted, + onToken: (text) => { + ws.send(JSON.stringify({ type: 'token', text })) + }, + onToolStart: (name) => { + ws.send(JSON.stringify({ type: 'tool_start', name })) + }, + onToolEnd: (name) => { + ws.send(JSON.stringify({ type: 'tool_end', name })) + }, + }) - if (groundingMetadata) { + if (result.groundingMetadata) { ws.send(JSON.stringify({ type: 'grounding_metadata', - groundingMetadata, + groundingMetadata: result.groundingMetadata, })) } @@ -280,13 +230,9 @@ export async function handleWsUpgrade( })) isCompleted = true - activeTxId = null ws.close(1000, 'Agent execution complete') } catch (adkErr) { console.error('ADK execution error:', adkErr) - if (!isCompleted) { - await refundIfNeeded() - } try { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'error', code: 'INTERNAL_ERROR', message: 'Agent execution failed' })) @@ -296,7 +242,6 @@ export async function handleWsUpgrade( } } catch (err) { console.error('agent_run handler error:', err) - await refundIfNeeded() try { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'error', code: 'INTERNAL_ERROR', message: 'Internal server error' })) @@ -352,7 +297,6 @@ export async function handleWsUpgrade( clearTimeout(authTimer) if (abortController && !isCompleted) { abortController.abort() - void refundIfNeeded() } }) diff --git a/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts b/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts index 8f274245..74c614d4 100644 --- a/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts +++ b/cloud-agent/src/handlers/wsLiveAgentHandler.test.ts @@ -42,8 +42,8 @@ function makeMockDb(queryRowSets: Record[][] = []) { } const mockCreditService = { - spendCredit: async (_userId: string): Promise => 'mock-txid', - refundCredit: async (_userId: string, _txId: string): Promise => {}, + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 5 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, getBalance: async (_userId: string): Promise => 42, } @@ -273,6 +273,69 @@ test('one credit at open closes with 4402', async () => { await close() }) +test('four credits at open closes with 4402 (below new gate)', async () => { + const db = makeMockDb([[mockUser]]) + const cs = { ...mockCreditService, getBalance: async () => 4 } + const mock = makeMockLiveConnect() + const { server, close } = createLiveTestServer({ + db, + creditService: cs, + verifyToken: async () => ({ uid: 'uid' }), + liveConnect: mock.connect, + }) + const port = await listen(server) + + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + const timeout = setTimeout(() => reject(new Error('test timeout')), 5000) + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID })) + }) + ws.on('close', (code) => { + clearTimeout(timeout) + assert.equal(code, 4402) + resolve() + }) + ws.on('error', reject) + }) + + await close() +}) + +test('five credits at open allows the session to proceed', async () => { + const db = makeMockDb([[mockUser], [mockCharacter]]) + const cs = { ...mockCreditService, getBalance: async () => 5 } + const mock = makeMockLiveConnect() + const { server, close } = createLiveTestServer({ + db, + creditService: cs, + verifyToken: async () => ({ uid: 'uid' }), + liveConnect: mock.connect, + billingIntervalMs: 60_000, + }) + const port = await listen(server) + + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + const timeout = setTimeout(() => reject(new Error('test timeout')), 5000) + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID })) + }) + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) as { type: string; remainingCredits?: number } + if (msg.type === 'session_ready') { + clearTimeout(timeout) + assert.equal(msg.remainingCredits, 5) + ws.close() + resolve() + } + }) + ws.on('error', reject) + }) + + await close() +}) + test('valid auth sends session_ready with balance', async () => { const db = makeMockDb([[mockUser], [mockCharacter]]) const cs = { ...mockCreditService, getBalance: async () => 77 } @@ -733,7 +796,7 @@ test('billing tick with INSUFFICIENT_CREDITS sends usage_snapshot(0) + error + c const db = makeMockDb([[mockUser], [mockCharacter]]) const cs = { ...mockCreditService, - spendCredit: async (): Promise => { + spendCredit: async (): Promise<{ transactionId: string; amount: number }[]> => { throw new Error('INSUFFICIENT_CREDITS') }, } @@ -778,10 +841,10 @@ test('billing ticks do not overlap when spendCredit is slow', async () => { let spendCalls = 0 const cs = { ...mockCreditService, - spendCredit: async (): Promise => { + spendCredit: async (): Promise<{ transactionId: string; amount: number }[]> => { spendCalls++ await new Promise((resolve) => setTimeout(resolve, 120)) - return 'mock-txid' + return [{ transactionId: 'mock-txid', amount: 5 }] }, } const db = makeMockDb([[mockUser], [mockCharacter]]) diff --git a/cloud-agent/src/handlers/wsLiveAgentHandler.ts b/cloud-agent/src/handlers/wsLiveAgentHandler.ts index 74d73093..67bebc55 100644 --- a/cloud-agent/src/handlers/wsLiveAgentHandler.ts +++ b/cloud-agent/src/handlers/wsLiveAgentHandler.ts @@ -313,7 +313,7 @@ export async function handleLiveWsUpgrade( userId = dbUser.id const balance = await cs.getBalance(userId) - if (balance < 2) { + if (balance < 5) { ws.send(JSON.stringify({ type: 'error', code: 'INSUFFICIENT_CREDITS', message: 'Insufficient credits' })) ws.close(4402, 'Insufficient credits') return @@ -336,7 +336,7 @@ export async function handleLiveWsUpgrade( billingInFlight = true void (async () => { try { - await cs.spendCredit(userId!) + await cs.spendCredit(userId!, 5) let newBalance: number try { newBalance = await cs.getBalance(userId!) diff --git a/cloud-agent/src/index.test.ts b/cloud-agent/src/index.test.ts index 87700ed5..06b98171 100644 --- a/cloud-agent/src/index.test.ts +++ b/cloud-agent/src/index.test.ts @@ -82,8 +82,8 @@ const mockRunAgent = async (_params: RunAgentParams): Promise<{ reply: string; t }) const mockCreditService = { - spendCredit: async (_userId: string): Promise => 'mock-txid', - refundCredit: async (_userId: string, _txId: string): Promise => {}, + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 1 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, getBalance: async (_userId: string): Promise => 42, } @@ -319,61 +319,26 @@ test('POST /agent/run rate-limits after 20 requests in 60s window', async () => // ── Credit service integration ──────────────────────────────────────────────── -test('POST /agent/run returns 402 when spendCredit throws INSUFFICIENT_CREDITS', async () => { +test('POST /agent/run returns 402 when balance is zero before agent starts', async () => { const db = makeMockDb([[mockUser] as InsertedRow[], [mockCharacter] as InsertedRow[], []]) const cs = { ...mockCreditService, - spendCredit: async (_userId: string): Promise => { - throw new Error('INSUFFICIENT_CREDITS') - }, + getBalance: async (_userId: string) => 0, } - const app = createApp({ verifyToken: mockVerify, db, runAgentFn: mockRunAgent, creditService: cs }) + let agentCalled = false + const app = createApp({ + verifyToken: mockVerify, + db, + runAgentFn: async () => { agentCalled = true; return { reply: 'ok', toolCalls: [] } }, + creditService: cs, + }) const res = await request(app) .post('/agent/run') .set('Authorization', 'Bearer valid-token') .send({ message: 'hello', characterId: CHAR_UUID }) assert.equal(res.status, 402) assert.deepEqual(res.body, { error: 'Insufficient credits' }) -}) - -test('POST /agent/run calls refundCredit and returns 500 when runAgentFn throws', async () => { - const db = makeMockDb([[mockUser] as InsertedRow[], [mockCharacter] as InsertedRow[], []]) - let refundCalled = false - const cs = { - ...mockCreditService, - refundCredit: async (_userId: string, _txId: string): Promise => { refundCalled = true }, - } - const failingAgent = async (_params: RunAgentParams): Promise<{ reply: string; toolCalls: string[] }> => { - throw new Error('ADK error (unknown): vertex safety block') - } - const app = createApp({ verifyToken: mockVerify, db, runAgentFn: failingAgent, creditService: cs }) - const res = await request(app) - .post('/agent/run') - .set('Authorization', 'Bearer valid-token') - .send({ message: 'hello', characterId: CHAR_UUID }) - assert.equal(res.status, 500) - assert.ok(refundCalled, 'expected refundCredit to be called') - assert.match((res.body as { error: string }).error, /ADK error/) -}) - -test('POST /agent/run swallows refundCredit failure and still returns 500 with ADK error', async () => { - const db = makeMockDb([[mockUser] as InsertedRow[], [mockCharacter] as InsertedRow[], []]) - const cs = { - ...mockCreditService, - refundCredit: async (_userId: string, _txId: string): Promise => { - throw new Error('connection lost during refund') - }, - } - const failingAgent = async (_params: RunAgentParams): Promise<{ reply: string; toolCalls: string[] }> => { - throw new Error('ADK error (unknown): vertex failed') - } - const app = createApp({ verifyToken: mockVerify, db, runAgentFn: failingAgent, creditService: cs }) - const res = await request(app) - .post('/agent/run') - .set('Authorization', 'Bearer valid-token') - .send({ message: 'hello', characterId: CHAR_UUID }) - assert.equal(res.status, 500) - assert.match((res.body as { error: string }).error, /ADK error/) + assert.ok(!agentCalled, 'runAgentFn must not be called when balance is zero') }) test('POST /agent/run returns usageSnapshot.remainingCredits on success', async () => { @@ -403,26 +368,6 @@ test('POST /agent/run returns usageSnapshot: null and 200 when getBalance throws assert.equal((res.body as { usageSnapshot: unknown }).usageSnapshot, null) }) -test('POST /agent/run does not call runAgentFn when spendCredit throws INSUFFICIENT_CREDITS', async () => { - const db = makeMockDb([[mockUser] as InsertedRow[], [mockCharacter] as InsertedRow[], []]) - const cs = { - ...mockCreditService, - spendCredit: async (_userId: string): Promise => { throw new Error('INSUFFICIENT_CREDITS') }, - } - let agentCalled = false - const app = createApp({ - verifyToken: mockVerify, - db, - runAgentFn: async (params) => { agentCalled = true; return { reply: 'ok', toolCalls: [] } }, - creditService: cs, - }) - await request(app) - .post('/agent/run') - .set('Authorization', 'Bearer valid-token') - .send({ message: 'hello', characterId: CHAR_UUID }) - assert.ok(!agentCalled, 'runAgentFn must not be called when credits are insufficient') -}) - test('POST /agent/run captures X-Timezone header and passes it to runAgentFn', async () => { const db = makeMockDb([[mockUser] as InsertedRow[], [mockCharacter] as InsertedRow[], []]) let capturedTimezone = '' diff --git a/cloud-agent/src/index.ts b/cloud-agent/src/index.ts index d4ebdd80..d60cbba8 100644 --- a/cloud-agent/src/index.ts +++ b/cloud-agent/src/index.ts @@ -4,12 +4,11 @@ import cors from 'cors' import { rateLimit } from 'express-rate-limit' import admin from 'firebase-admin' import { eq, and } from 'drizzle-orm' -import { InMemoryRunner, isFinalResponse, createEvent, createEventActions } from '@google/adk' +import { InMemoryRunner, createEvent, createEventActions } from '@google/adk' import type { Content, GroundingMetadata } from '@google/genai' import { WebSocketServer } from 'ws' import { getDb } from './db/client.js' import { buildAgent } from './agent.js' -import { hasGroundingData } from './groundingMetadata.js' import { assembleSystemInstruction, queryWikiContext } from './services/agentCore.js' import { bulkInsertUnsynced } from './services/unsyncedHistory.js' import { users, characters } from './db/schema.js' @@ -17,6 +16,7 @@ import { embedText } from './db/embeddings.js' import type { DrizzleClient } from './db/client.js' import { createCreditService } from './services/creditService.js' import type { CreditService } from './services/creditService.js' +import { assertAgentTurnCredits, AgentInsufficientCreditsError, consumeAgentEvents } from './services/agentEventLoop.js' import { handleWsUpgrade, type WsHandlerOptions } from './handlers/wsAgentHandler.js' import { handleLiveWsUpgrade, type WsLiveHandlerOptions } from './handlers/wsLiveAgentHandler.js' import { handleBrowserWsUpgrade } from './handlers/wsBrowserAgentHandler.js' @@ -48,6 +48,7 @@ export interface RunAgentParams { history: Content[] timezone: string embed: (text: string) => Promise + creditService: Pick } export interface AppOptions { @@ -63,7 +64,7 @@ export interface AppOptions { // ── Real agent runner (production) ──────────────────────────────────────────── export async function runAgentReal(params: RunAgentParams): Promise<{ reply: string; toolCalls: string[]; groundingMetadata?: GroundingMetadata }> { - const { db, userId, firebaseUid, characterId, systemInstruction, message, history, timezone, embed } = params + const { db, userId, firebaseUid, characterId, systemInstruction, message, history, timezone, embed, creditService } = params const bridge = admin.apps.length ? { firebaseUid, userId, @@ -102,39 +103,7 @@ export async function runAgentReal(params: RunAgentParams): Promise<{ reply: str newMessage: { role: 'user', parts: [{ text: message }] }, }) - let reply = '' - const toolCalls: string[] = [] - // Google Search grounding ToS requires surfacing the citation/search-suggestions - // Gemini returns. ADK exposes it as event.groundingMetadata (Event extends - // LlmResponse). Keep the last non-empty one — it rides the final response event. - let groundingMetadata: GroundingMetadata | undefined - for await (const event of events) { - if (event.errorCode || event.errorMessage) { - throw new Error(`ADK error (${event.errorCode ?? 'unknown'}): ${event.errorMessage ?? 'no message'}`) - } - if (event.content?.parts) { - for (const part of event.content.parts) { - if ('functionCall' in part) { - const fc = (part as { functionCall?: { name?: string } }).functionCall - if (fc?.name) toolCalls.push(fc.name) - } - } - } - if (hasGroundingData(event.groundingMetadata)) { - groundingMetadata = event.groundingMetadata - } - if (isFinalResponse(event) && event.content?.parts) { - reply = event.content.parts - .filter((p) => 'text' in p) - .map((p) => (p as { text: string }).text) - .join('') - } - } - - if (!reply.trim()) { - throw new Error('ADK returned an empty final reply') - } - return { reply, toolCalls, groundingMetadata } + return consumeAgentEvents(events, userId, creditService) } // ── App factory ─────────────────────────────────────────────────────────────── @@ -255,19 +224,6 @@ export function createApp(options: AppOptions) { ) if (!character) { res.status(404).json({ error: 'Character not found' }); return } - // SPEND FIRST — fail fast with 402 before any non-essential work or writes - let txId: string - try { - txId = await cs.spendCredit(userId) - } catch (creditErr: unknown) { - const msg = creditErr instanceof Error ? creditErr.message : '' - if (msg === 'INSUFFICIENT_CREDITS') { - res.status(402).json({ error: 'Insufficient credits' }) - return - } - throw creditErr - } - if (unsyncedHistory.length > 0) { try { await bulkInsertUnsynced(db, userId, characterId, unsyncedHistory, embedText) @@ -277,33 +233,24 @@ export function createApp(options: AppOptions) { } } - let wikiContext: string - let systemInstruction: string - try { - wikiContext = await queryWikiContext(db, message, userId, characterId, embedText) - systemInstruction = assembleSystemInstruction(character, wikiContext) - } catch (preAgentErr) { - try { - await cs.refundCredit(userId, txId) - } catch (refundErr) { - console.error(`[CRITICAL] refundCredit failed user=${userId} txId=${txId}`, refundErr) - } - throw preAgentErr - } - // 2. EXECUTE — refund on ADK failure - let result: { reply: string; toolCalls: string[]; groundingMetadata?: GroundingMetadata } + const wikiContext = await queryWikiContext(db, message, userId, characterId, embedText) + const systemInstruction = assembleSystemInstruction(character, wikiContext) + try { - result = await runAgentFn({ db, userId, firebaseUid, characterId, systemInstruction, message, history, timezone, embed: embedText }) - } catch (adkErr) { - try { - await cs.refundCredit(userId, txId) - } catch (refundErr) { - console.error(`[CRITICAL] refundCredit failed user=${userId} txId=${txId}`, refundErr) + await assertAgentTurnCredits(userId, cs) + } catch (creditErr) { + if (creditErr instanceof AgentInsufficientCreditsError) { + res.status(402).json({ error: 'Insufficient credits' }) + return } - throw adkErr + throw creditErr } - // 3. GET BALANCE — graceful degrade if this fails + // Credit spend happens per internal ADK loop iteration inside runAgentFn + // (see services/agentEventLoop.ts) — refund-on-failure is handled there too. + const result = await runAgentFn({ db, userId, firebaseUid, characterId, systemInstruction, message, history, timezone, embed: embedText, creditService: cs }) + + // GET BALANCE — graceful degrade if this fails let newBalance: number | null = null try { newBalance = await cs.getBalance(userId) @@ -311,7 +258,7 @@ export function createApp(options: AppOptions) { console.warn(`getBalance failed user=${userId}, returning null snapshot`, balErr) } - // 4. RESPOND + // RESPOND res.json({ reply: result.reply, toolCalls: result.toolCalls, diff --git a/cloud-agent/src/integration.test.ts b/cloud-agent/src/integration.test.ts index 9c3c35c8..0cf63243 100644 --- a/cloud-agent/src/integration.test.ts +++ b/cloud-agent/src/integration.test.ts @@ -57,8 +57,8 @@ const mockRunAgent = async (_params: RunAgentParams): Promise<{ reply: string; t }) const mockCreditService = { - spendCredit: async (_userId: string): Promise => 'mock-txid', - refundCredit: async (_userId: string, _txId: string): Promise => {}, + spendCredit: async (_userId: string): Promise<{ transactionId: string; amount: number }[]> => [{ transactionId: 'mock-txid', amount: 1 }], + refundCredit: async (_userId: string, _allocations: { transactionId: string; amount: number }[]): Promise => {}, getBalance: async (_userId: string): Promise => 42, } diff --git a/cloud-agent/src/services/agentEventLoop.test.ts b/cloud-agent/src/services/agentEventLoop.test.ts new file mode 100644 index 00000000..13bda5b1 --- /dev/null +++ b/cloud-agent/src/services/agentEventLoop.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { Event as AdkEvent } from '@google/adk' +import type { CreditSpendAllocation } from './creditService.js' + +const { consumeAgentEvents, assertAgentTurnCredits, AgentInsufficientCreditsError, DEGRADED_FALLBACK_REPLY } = await import('./agentEventLoop.js') + +function fakeEvent(overrides: Partial = {}): AdkEvent { + return { + id: `evt-${Math.random()}`, + invocationId: 'inv-1', + actions: {}, + ...overrides, + } as AdkEvent +} + +function textEvent(text: string): AdkEvent { + return fakeEvent({ content: { role: 'model', parts: [{ text }] } }) +} + +function functionCallEvent(name: string): AdkEvent { + return fakeEvent({ content: { role: 'model', parts: [{ functionCall: { name, args: {} } }] } }) +} + +async function* toAsyncIterable(events: AdkEvent[]): AsyncGenerator { + for (const e of events) yield e +} + +const FALLBACK_REPLY = DEGRADED_FALLBACK_REPLY + +test('assertAgentTurnCredits throws when balance is zero', async () => { + await assert.rejects( + () => assertAgentTurnCredits('user-1', { getBalance: async () => 0 }), + (err: Error) => err instanceof AgentInsufficientCreditsError, + ) +}) + +test('assertAgentTurnCredits passes when balance is positive', async () => { + await assert.doesNotReject(() => assertAgentTurnCredits('user-1', { getBalance: async () => 3 })) +}) + +test('assertAgentTurnCredits passes when getBalance throws (graceful degrade)', async () => { + await assert.doesNotReject(() => + assertAgentTurnCredits('user-1', { getBalance: async () => { throw new Error('db down') } }), + ) +}) + +test('consumeAgentEvents spends 1 credit per functionCall-bearing event and returns the final reply', async () => { + const spendCalls: string[] = [] + const cs = { + spendCredit: async (userId: string) => { + spendCalls.push(userId) + return [{ transactionId: `tx-${spendCalls.length}`, amount: 1 }] as CreditSpendAllocation[] + }, + refundCredit: async () => {}, + } + const events = toAsyncIterable([functionCallEvent('get_current_time'), textEvent('It is 3pm.')]) + const result = await consumeAgentEvents(events, 'user-1', cs) + assert.equal(spendCalls.length, 1) + assert.deepEqual(spendCalls, ['user-1']) + assert.equal(result.reply, 'It is 3pm.') + assert.deepEqual(result.toolCalls, ['get_current_time']) +}) + +test('consumeAgentEvents hard-stops at 5 loop iterations and returns a fallback reply', async () => { + const cs = { + spendCredit: async () => [{ transactionId: 'tx', amount: 1 }] as CreditSpendAllocation[], + refundCredit: async () => {}, + } + const events = toAsyncIterable([ + functionCallEvent('tool_a'), + functionCallEvent('tool_b'), + functionCallEvent('tool_c'), + functionCallEvent('tool_d'), + functionCallEvent('tool_e'), + functionCallEvent('tool_f'), // never consumed — loop stops after the 5th + ]) + const result = await consumeAgentEvents(events, 'user-1', cs) + assert.equal(result.toolCalls.length, 5) + assert.equal(result.reply, FALLBACK_REPLY) +}) + +test('consumeAgentEvents degrades gracefully when credits run out mid-loop (no throw, no refund)', async () => { + let calls = 0 + const cs = { + spendCredit: async () => { + calls += 1 + if (calls === 2) throw new Error('INSUFFICIENT_CREDITS') + return [{ transactionId: `tx-${calls}`, amount: 1 }] as CreditSpendAllocation[] + }, + refundCredit: async () => { + throw new Error('refundCredit should not be called on a graceful mid-loop degrade') + }, + } + const events = toAsyncIterable([ + functionCallEvent('tool_a'), + functionCallEvent('tool_b'), // this spend throws INSUFFICIENT_CREDITS + functionCallEvent('tool_c'), // never reached + ]) + const result = await consumeAgentEvents(events, 'user-1', cs) + assert.equal(result.toolCalls.length, 2) + assert.equal(result.reply, FALLBACK_REPLY) +}) + +test('consumeAgentEvents refunds only the credits spent this turn on a genuine ADK error', async () => { + let calls = 0 + const refunded: unknown[] = [] + const cs = { + spendCredit: async () => { + calls += 1 + return [{ transactionId: `tx-${calls}`, amount: 1 }] as CreditSpendAllocation[] + }, + refundCredit: async (_userId: string, allocations: CreditSpendAllocation[]) => { + refunded.push(allocations) + }, + } + const events = toAsyncIterable([ + functionCallEvent('tool_a'), + functionCallEvent('tool_b'), + fakeEvent({ errorCode: 'SAFETY', errorMessage: 'blocked' }), + ]) + await assert.rejects( + () => consumeAgentEvents(events, 'user-1', cs), + (err: Error) => { + assert.match(err.message, /ADK error \(SAFETY\)/) + return true + }, + ) + assert.deepEqual(refunded, [[ + { transactionId: 'tx-1', amount: 1 }, + { transactionId: 'tx-2', amount: 1 }, + ]]) +}) + +test('consumeAgentEvents throws when the loop completes normally with an empty final reply', async () => { + const cs = { + spendCredit: async () => [{ transactionId: 'tx', amount: 1 }] as CreditSpendAllocation[], + refundCredit: async () => {}, + } + const events = toAsyncIterable([fakeEvent({ content: { role: 'model', parts: [{ text: '' }] } })]) + await assert.rejects( + () => consumeAgentEvents(events, 'user-1', cs), + (err: Error) => { + assert.equal(err.message, 'ADK returned an empty final reply') + return true + }, + ) +}) diff --git a/cloud-agent/src/services/agentEventLoop.ts b/cloud-agent/src/services/agentEventLoop.ts new file mode 100644 index 00000000..1320d4f9 --- /dev/null +++ b/cloud-agent/src/services/agentEventLoop.ts @@ -0,0 +1,186 @@ +import { isFinalResponse } from '@google/adk' +import type { Event as AdkEvent } from '@google/adk' +import type { GroundingMetadata } from '@google/genai' +import { hasGroundingData } from '../groundingMetadata.js' +import type { CreditService, CreditSpendAllocation } from './creditService.js' + +export const MAX_LOOP_ITERATIONS = 5 +export const DEGRADED_FALLBACK_REPLY = + "I've done what I can for now — let me know if you'd like me to continue." + +export class AgentInsufficientCreditsError extends Error { + constructor() { + super('INSUFFICIENT_CREDITS') + this.name = 'AgentInsufficientCreditsError' + } +} + +export interface ConsumeAgentEventsResult { + reply: string + toolCalls: string[] + groundingMetadata?: GroundingMetadata + degraded?: boolean +} + +export interface ConsumeAgentEventsHooks { + onToken?: (text: string) => void + onToolStart?: (name: string) => void + onToolEnd?: (name: string) => void + onGroundingMetadata?: (metadata: GroundingMetadata) => void + shouldAbort?: () => boolean +} + +function eventHasFunctionCall(event: AdkEvent): boolean { + return event.content?.parts?.some((part) => 'functionCall' in part) ?? false +} + +function extractText(event: AdkEvent): string { + if (!event.content?.parts) return '' + return event.content.parts + .filter((p): p is { text: string } => 'text' in p) + .map((p) => p.text) + .join('') +} + +/** Reject agent turns when the user has no credits before any ADK work begins. */ +export async function assertAgentTurnCredits( + userId: string, + creditService: Pick, +): Promise { + let balance: number + try { + balance = await creditService.getBalance(userId) + } catch { + // Can't verify balance pre-flight — allow the turn; per-loop spend will gate if needed. + return + } + if (balance < 1) { + throw new AgentInsufficientCreditsError() + } +} + +/** + * Consumes one ADK agent run's event stream, billing 1 credit per internal + * tool-call loop iteration (capped at MAX_LOOP_ITERATIONS) instead of a flat + * per-turn charge. Hitting the cap, or running out of credits mid-loop, stops + * the stream early and returns a graceful fallback reply rather than throwing — + * only a genuine ADK error refunds the credits already spent this turn. + */ +export async function consumeAgentEvents( + events: AsyncIterable, + userId: string, + creditService: Pick, + hooks?: ConsumeAgentEventsHooks, +): Promise { + let reply = '' + let lastText = '' + let streamedAnyText = false + const toolCalls: string[] = [] + let groundingMetadata: GroundingMetadata | undefined + let loopCount = 0 + let degraded = false + let lastToolName: string | null = null + const spentAllocations: CreditSpendAllocation[] = [] + + const emitToken = (text: string) => { + if (!text) return + streamedAnyText = true + hooks?.onToken?.(text) + } + + try { + for await (const event of events) { + if (hooks?.shouldAbort?.()) { + degraded = true + break + } + + if (event.errorCode || event.errorMessage) { + throw new Error(`ADK error (${event.errorCode ?? 'unknown'}): ${event.errorMessage ?? 'no message'}`) + } + + if (eventHasFunctionCall(event)) { + for (const part of event.content!.parts!) { + if ('functionCall' in part) { + const fc = (part as { functionCall?: { name?: string } }).functionCall + if (fc?.name) { + toolCalls.push(fc.name) + if (fc.name !== lastToolName) { + hooks?.onToolStart?.(fc.name) + lastToolName = fc.name + } + } + } + } + + loopCount += 1 + try { + const allocations = await creditService.spendCredit(userId) + spentAllocations.push(...allocations) + } catch (creditErr) { + const msg = creditErr instanceof Error ? creditErr.message : '' + if (msg === 'INSUFFICIENT_CREDITS') { + degraded = true + break + } + throw creditErr + } + + if (loopCount === MAX_LOOP_ITERATIONS) { + degraded = true + break + } + } + + if (lastToolName && event.content && !event.content.parts?.some((p) => 'functionCall' in p)) { + hooks?.onToolEnd?.(lastToolName) + lastToolName = null + } + + if (hasGroundingData(event.groundingMetadata)) { + groundingMetadata = event.groundingMetadata + hooks?.onGroundingMetadata?.(event.groundingMetadata) + } + + const text = extractText(event) + if (text) { + lastText = text + if (event.content?.parts) { + for (const part of event.content.parts) { + if ('text' in part) { + const token = (part as { text: string }).text + if (token) emitToken(token) + } + } + } + } + + if (isFinalResponse(event) && event.content?.parts) { + reply = text + } + } + + if (!reply.trim()) { + if (degraded) { + reply = lastText.trim() || DEGRADED_FALLBACK_REPLY + } else { + throw new Error('ADK returned an empty final reply') + } + } + + if (degraded && !streamedAnyText && hooks?.onToken) { + emitToken(reply) + } + } catch (err) { + if (spentAllocations.length > 0) { + try { + await creditService.refundCredit(userId, spentAllocations) + } catch (refundErr) { + console.error(`[CRITICAL] refundCredit failed user=${userId}`, refundErr) + } + } + throw err + } + + return { reply, toolCalls, groundingMetadata, degraded: degraded || undefined } +} diff --git a/cloud-agent/src/services/creditService.test.ts b/cloud-agent/src/services/creditService.test.ts index 47abb17e..201e89dc 100644 --- a/cloud-agent/src/services/creditService.test.ts +++ b/cloud-agent/src/services/creditService.test.ts @@ -22,15 +22,21 @@ const { createCreditService } = await import('./creditService.js') // ── spendCredit ─────────────────────────────────────────────────────────────── -test('spendCredit returns txId when a qualifying row exists', async () => { - // Call 1: INSERT subscriptions (ensure row exists) - // Call 2: SELECT FOR UPDATE on subscriptions (lock ordering) - // Call 3: SELECT SUM(...) net active balance (must be >= 1) - // Call 4: UPDATE credit_transactions RETURNING id; Call 5: UPDATE subscriptions current_credits cache - const db = makeExecuteDb([{ rows: [] }, { rows: [{ user_id: 'user-1' }] }, { rows: [{ total: '1' }] }, { rows: [{ id: 'tx-abc' }] }, { rows: [] }]) +test('spendCredit returns an allocation array when a qualifying row exists', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE subscriptions + // Call 3: SELECT SUM net active balance, Call 4: SELECT id, remaining_balance FOR UPDATE + // Call 5: UPDATE credit_transactions, Call 6: UPDATE subscriptions cache + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ total: '1' }] }, + { rows: [{ id: 'tx-abc', remaining_balance: '1' }] }, + { rows: [] }, + { rows: [] }, + ]) const cs = createCreditService(db) - const txId = await cs.spendCredit('user-1') - assert.equal(txId, 'tx-abc') + const allocations = await cs.spendCredit('user-1', 1) + assert.deepEqual(allocations, [{ transactionId: 'tx-abc', amount: 1 }]) }) test('spendCredit throws INSUFFICIENT_CREDITS when no qualifying row', async () => { @@ -68,16 +74,99 @@ test('spendCredit does not update subscriptions when spend fails', async () => { assert.equal(executeCalls, 3) }) +test('spendCredit spans multiple rows when amount exceeds the first row balance', async () => { + // Call 1: INSERT subscriptions + // Call 2: SELECT FOR UPDATE on subscriptions + // Call 3: SELECT SUM(...) net active balance -> '7' (>= 5) + // Call 4: SELECT id, remaining_balance ... FOR UPDATE -> two rows (3, then 4) + // Call 5: UPDATE credit_transactions row tx-1 (- 3) + // Call 6: UPDATE credit_transactions row tx-2 (- 2) + // Call 7: UPDATE subscriptions current_credits cache + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ total: '7' }] }, + { rows: [{ id: 'tx-1', remaining_balance: '3' }, { id: 'tx-2', remaining_balance: '4' }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + const allocations = await cs.spendCredit('user-1', 5) + assert.deepEqual(allocations, [ + { transactionId: 'tx-1', amount: 3 }, + { transactionId: 'tx-2', amount: 2 }, + ]) +}) + +test('spendCredit throws INSUFFICIENT_CREDITS when net balance across all rows is short', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE, Call 3: SELECT SUM -> '3' (< 5) + const db = makeExecuteDb([{ rows: [] }, { rows: [{ user_id: 'user-1' }] }, { rows: [{ total: '3' }] }]) + const cs = createCreditService(db) + await assert.rejects( + () => cs.spendCredit('user-1', 5), + (err: Error) => { + assert.equal(err.message, 'INSUFFICIENT_CREDITS') + return true + }, + ) +}) + +test('spendCredit defaults amount to 1 when not passed', async () => { + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ total: '1' }] }, + { rows: [{ id: 'tx-abc', remaining_balance: '1' }] }, + { rows: [] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + const allocations = await cs.spendCredit('user-1') + assert.deepEqual(allocations, [{ transactionId: 'tx-abc', amount: 1 }]) +}) + // ── refundCredit ────────────────────────────────────────────────────────────── test('refundCredit resolves without throwing', async () => { - // Call 1: INSERT subscriptions (ensure row exists) - // Call 2: SELECT FOR UPDATE on subscriptions (lock ordering) - // Call 3: UPDATE credit_transactions SET remaining_balance + 1 - // Call 4: UPDATE subscriptions SET current_credits + 1 - const db = makeExecuteDb([{ rows: [] }, { rows: [{ user_id: 'user-1' }] }, { rows: [] }, { rows: [] }]) + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE subscriptions + // Call 3: UPDATE credit_transactions RETURNING id, Call 4: UPDATE subscriptions cache + const db = makeExecuteDb([{ rows: [] }, { rows: [{ user_id: 'user-1' }] }, { rows: [{ id: 'tx-abc' }] }, { rows: [] }]) + const cs = createCreditService(db) + await assert.doesNotReject(() => cs.refundCredit('user-1', [{ transactionId: 'tx-abc', amount: 1 }])) +}) + +test('refundCredit restores every row in a multi-row allocation atomically', async () => { + // Call 1: INSERT subscriptions, Call 2: SELECT FOR UPDATE subscriptions + // Call 3: UPDATE credit_transactions tx-1 RETURNING id + // Call 4: UPDATE credit_transactions tx-2 RETURNING id + // Call 5: UPDATE subscriptions cache + const db = makeExecuteDb([ + { rows: [] }, + { rows: [{ user_id: 'user-1' }] }, + { rows: [{ id: 'tx-1' }] }, + { rows: [{ id: 'tx-2' }] }, + { rows: [] }, + ]) + const cs = createCreditService(db) + await assert.doesNotReject(() => + cs.refundCredit('user-1', [ + { transactionId: 'tx-1', amount: 3 }, + { transactionId: 'tx-2', amount: 2 }, + ]), + ) +}) + +test('refundCredit is a no-op for an empty allocation array', async () => { + let executeCalls = 0 + const db = { + execute: async () => { executeCalls++; return { rows: [] } }, + transaction: async (callback: (tx: DrizzleClient) => Promise) => + callback({ execute: async () => { executeCalls++; return { rows: [] } } } as unknown as DrizzleClient), + } as unknown as DrizzleClient const cs = createCreditService(db) - await assert.doesNotReject(() => cs.refundCredit('user-1', 'tx-abc')) + await cs.refundCredit('user-1', []) + assert.equal(executeCalls, 0) }) test('refundCredit makes correct number of execute calls', async () => { @@ -92,7 +181,7 @@ test('refundCredit makes correct number of execute calls', async () => { }, } as unknown as DrizzleClient const cs = createCreditService(db) - await cs.refundCredit('user-1', 'tx-abc') + await cs.refundCredit('user-1', [{ transactionId: 'tx-abc', amount: 1 }]) // Inside transaction: // - INSERT subscriptions (1) // - SELECT FOR UPDATE on subscriptions (2) diff --git a/cloud-agent/src/services/creditService.ts b/cloud-agent/src/services/creditService.ts index 4dd4a8bc..77d34f61 100644 --- a/cloud-agent/src/services/creditService.ts +++ b/cloud-agent/src/services/creditService.ts @@ -1,29 +1,30 @@ import { sql } from 'drizzle-orm' import type { DrizzleClient } from '../db/client.js' +export type CreditSpendAllocation = { + transactionId: string + amount: number +} + export type CreditService = { - spendCredit: (userId: string) => Promise - refundCredit: (userId: string, txId: string) => Promise + spendCredit: (userId: string, amount?: number) => Promise + refundCredit: (userId: string, allocations: CreditSpendAllocation[]) => Promise getBalance: (userId: string) => Promise } export function createCreditService(db: DrizzleClient): CreditService { return { - async spendCredit(userId: string): Promise { - // Match functions lock order to prevent deadlocks: + async spendCredit(userId: string, amount = 1): Promise { + // Match functions/ lock order to prevent deadlocks: // 1. Ensure subscriptions row exists and lock it first // 2. Then lock and update credit_transactions - // This matches functions/src/services/creditService.ts lock ordering. - return await db.transaction(async (tx) => { - // Ensure subscriptions row exists (ON CONFLICT DO NOTHING is idempotent) await tx.execute(sql` INSERT INTO subscriptions (user_id, current_credits) VALUES (${userId}, 0) ON CONFLICT (user_id) DO NOTHING `) - // Lock the subscriptions row first await tx.execute(sql` SELECT user_id FROM subscriptions WHERE user_id = ${userId} @@ -38,36 +39,39 @@ export function createCreditService(db: DrizzleClient): CreditService { AND (expires_at IS NULL OR expires_at > NOW()) `) const netCredits = Number(netResult.rows[0]?.total ?? 0) - if (netCredits < 1) { + if (netCredits < amount) { throw new Error('INSUFFICIENT_CREDITS') } - // Atomically selects the earliest-expiring row with remaining_balance >= 1 - // and decrements it. Returns 0 rows if no qualifying row exists. - // Two concurrent requests with 1 credit: PostgreSQL row locking ensures - // only one succeeds; the second sees remaining_balance = 0 and returns 0 rows. - const spendResult = await tx.execute<{ id: string }>(sql` - UPDATE credit_transactions - SET remaining_balance = remaining_balance - 1 + // Lock every qualifying row FIFO (expiring soonest first), then allocate + // the requested amount across as many rows as needed. + const rows = await tx.execute<{ id: string; remaining_balance: string }>(sql` + SELECT id, remaining_balance FROM credit_transactions WHERE user_id = ${userId} - AND remaining_balance >= 1 + AND remaining_balance > 0 AND (expires_at IS NULL OR expires_at > NOW()) - AND id = ( - SELECT id FROM credit_transactions - WHERE user_id = ${userId} - AND remaining_balance >= 1 - AND (expires_at IS NULL OR expires_at > NOW()) - ORDER BY expires_at ASC NULLS LAST - LIMIT 1 FOR UPDATE - ) - RETURNING id + ORDER BY expires_at ASC NULLS LAST, id ASC + FOR UPDATE `) - if (spendResult.rows.length === 0) { - throw new Error('INSUFFICIENT_CREDITS') + let remaining = amount + const allocations: CreditSpendAllocation[] = [] + for (const row of rows.rows) { + if (remaining <= 0) break + const take = Math.min(Number(row.remaining_balance), remaining) + await tx.execute(sql` + UPDATE credit_transactions + SET remaining_balance = remaining_balance - ${take} + WHERE id = ${row.id} + `) + allocations.push({ transactionId: row.id, amount: take }) + remaining -= take } - const txId = spendResult.rows[0].id + if (remaining > 0 || allocations.length === 0) { + // Net balance passed under lock but rows could not cover it — should be unreachable. + throw new Error('INSUFFICIENT_CREDITS') + } // Update subscriptions cache (row is already locked) try { @@ -86,12 +90,15 @@ export function createCreditService(db: DrizzleClient): CreditService { console.warn(`subscriptions.current_credits decrement failed user=${userId}`, err) } - return txId + return allocations }) }, - async refundCredit(userId: string, txId: string): Promise { - // Match functions lock order: lock subscriptions first, then credit_transactions + async refundCredit(userId: string, allocations: CreditSpendAllocation[]): Promise { + if (allocations.length === 0) { + return + } + await db.transaction(async (tx) => { await tx.execute(sql` INSERT INTO subscriptions (user_id, current_credits) @@ -105,29 +112,25 @@ export function createCreditService(db: DrizzleClient): CreditService { FOR UPDATE `) - const updated = await tx.execute<{ id: string }>(sql` - UPDATE credit_transactions - SET remaining_balance = remaining_balance + 1 - WHERE id = ${txId} - AND user_id = ${userId} - AND (expires_at IS NULL OR expires_at > NOW()) - RETURNING id - `) - - if (updated.rows.length === 0) { - // Original row expired between spend and refund; insert a non-expiring compensation - await tx.execute(sql` - INSERT INTO credit_transactions ( - user_id, - delta, - reason, - initial_amount, - remaining_balance, - transaction_type, - expires_at - ) - VALUES (${userId}, 1, 'refund_compensation', 1, 1, 'legacy', NULL) + for (const { transactionId, amount } of allocations) { + const updated = await tx.execute<{ id: string }>(sql` + UPDATE credit_transactions + SET remaining_balance = remaining_balance + ${amount} + WHERE id = ${transactionId} + AND user_id = ${userId} + AND (expires_at IS NULL OR expires_at > NOW()) + RETURNING id `) + + if (updated.rows.length === 0) { + // Original row expired between spend and refund; insert a non-expiring compensation. + await tx.execute(sql` + INSERT INTO credit_transactions ( + user_id, delta, reason, initial_amount, remaining_balance, transaction_type, expires_at + ) + VALUES (${userId}, ${amount}, 'refund_compensation', ${amount}, ${amount}, 'legacy', NULL) + `) + } } try { @@ -142,7 +145,6 @@ export function createCreditService(db: DrizzleClient): CreditService { WHERE user_id = ${userId} `) } catch (err) { - // Best-effort cache sync; credit_transactions is the source of truth. console.warn(`subscriptions.current_credits increment failed user=${userId}`, err) } }) @@ -159,4 +161,4 @@ export function createCreditService(db: DrizzleClient): CreditService { return total !== null && total !== undefined ? Number(total) : 0 }, } -} \ No newline at end of file +} diff --git a/cloud-agent/src/tools/browserAction.test.ts b/cloud-agent/src/tools/browserAction.test.ts index 8e093bcf..ae8c7c0b 100644 --- a/cloud-agent/src/tools/browserAction.test.ts +++ b/cloud-agent/src/tools/browserAction.test.ts @@ -26,7 +26,7 @@ function baseDeps(over: Record = {}) { }, }, fcmDispatcher: { wakeExtension: async () => { calls.wake++ } }, - creditService: { spendCredit: async () => { calls.spend++; return 'tx1' }, refundCredit: async () => { calls.refund++ } }, + creditService: { spendCredit: async () => { calls.spend++; return [{ transactionId: 'tx1', amount: 1 }] }, refundCredit: async () => { calls.refund++ } }, instanceId: 'i1', wakeTimeoutMs: 50, textTimeoutMs: 200, @@ -155,7 +155,7 @@ test('voice path: awaiting_auth narrates pause, resumes billing, ends turn (no E watchTask: (_u: string, _s: string, _t: string, cb: (t: Record) => void) => { taskWatcher = cb; return () => { unsubbed = true } }, } as never, fcmDispatcher: { wakeExtension: async () => {} } as never, - creditService: { spendCredit: async () => 'tx1', refundCredit: async () => {} } as never, + creditService: { spendCredit: async () => [{ transactionId: 'tx1', amount: 1 }], refundCredit: async () => {} } as never, instanceId: 'i-test', pushToLive: (_taskId: string, _sessionId: string, msg: string) => { pushed.push(msg) }, pauseBilling: () => {}, @@ -193,7 +193,7 @@ test('text path: awaiting_auth returns a phone-approval message', async () => { watchTask: (_u: string, _s: string, _t: string, cb: (t: Record) => void) => { taskWatcher = cb; return () => {} }, } as never, fcmDispatcher: { wakeExtension: async () => {} } as never, - creditService: { spendCredit: async () => 'tx1', refundCredit: async () => {} } as never, + creditService: { spendCredit: async () => [{ transactionId: 'tx1', amount: 1 }], refundCredit: async () => {} } as never, instanceId: 'i-test', wakeTimeoutMs: 50, textTimeoutMs: 200, diff --git a/cloud-agent/src/tools/browserAction.ts b/cloud-agent/src/tools/browserAction.ts index b96fa77b..9adc6ab2 100644 --- a/cloud-agent/src/tools/browserAction.ts +++ b/cloud-agent/src/tools/browserAction.ts @@ -2,7 +2,7 @@ import { FunctionTool } from '@google/adk' import { z } from 'zod' import type { FirestoreSession } from '../services/firestoreSession.js' import type { FcmDispatcher } from '../services/fcmDispatcher.js' -import type { CreditService } from '../services/creditService.js' +import type { CreditService, CreditSpendAllocation } from '../services/creditService.js' import type { TaskIntent, TaskDoc } from '../../../shared/dsl-types.js' import { intentRequiresAuth } from '../../../shared/constants.js' import { findBlockedNavigation } from '../../../shared/hostPolicy.js' @@ -90,10 +90,10 @@ export function browserActionTool( // 2. Contextual billing. deps.pauseBilling?.() - let txId: string | null = null + let allocations: CreditSpendAllocation[] | null = null let sessionCreated = false if (!context.preBilled) { - try { txId = await deps.creditService.spendCredit(deps.userId) } + try { allocations = await deps.creditService.spendCredit(deps.userId) } catch { deps.resumeBilling?.(); return 'You are out of credits for browser actions.' } } @@ -108,8 +108,8 @@ export function browserActionTool( // 4. Wake. await deps.fcmDispatcher.wakeExtension(device.fcmToken, sessionId, taskId) } catch (err) { - if (txId) { - try { await deps.creditService.refundCredit(deps.userId, txId) } catch { /* logged */ } + if (allocations) { + try { await deps.creditService.refundCredit(deps.userId, allocations) } catch { /* logged */ } } if (sessionCreated) { try { await fs.closeSession(deps.firebaseUid, sessionId, 'aborted') } catch { /* ignore */ } @@ -128,7 +128,7 @@ export function browserActionTool( error: { code: 'EXTENSION_OFFLINE', message: 'Browser extension did not connect', failedAction: action as never }, }) if (aborted) { - if (txId) { try { await deps.creditService.refundCredit(deps.userId, txId) } catch { /* logged */ } } + if (allocations) { try { await deps.creditService.refundCredit(deps.userId, allocations) } catch { /* logged */ } } await fs.closeSession(deps.firebaseUid, sessionId, 'aborted') } } diff --git a/docs/billing-and-credits.md b/docs/billing-and-credits.md index 1d154a75..1fff6780 100644 --- a/docs/billing-and-credits.md +++ b/docs/billing-and-credits.md @@ -23,20 +23,23 @@ Handled provider-side: Stripe, Apple App Store, and Google Play manage refund me ### Credit Consumption -Per-action costs. Firebase text/chat paths charge **per round-trip** (a multi-tool turn costs more); turn-based cloud-agent text calls charge a **flat 1 per turn**. Live voice is billed separately on a 60-second timer. This difference is intentional. +Per-action costs. Firebase text/chat paths charge **per round-trip** (a multi-tool turn costs more); cloud-agent text turns charge **per internal tool-call loop iteration, capped at 5**. Live voice is billed separately on a 60-second timer. This difference is intentional. | Action | Path | Cost | Refund on failure | |---|---|---|---| -| Text chat reply | `generateReply` (Functions) | 1 / round-trip (incl. tool rounds) | Yes | -| Image generation | `generateImage` | 1 | Yes | -| Document text conversion | `convertDocumentText` | 1 | Yes | +| Text chat reply (grounded) | `generateReply` (Functions), no explicit `tools` (default googleSearch) | 3 / round-trip | Yes | +| Text chat reply (standard) | `generateReply` (Functions), explicit `tools` supplied | 1 / round-trip | Yes | +| Image generation | `generateImage` | 2 | Yes | +| Document text conversion | `convertDocumentText` | 2 | Yes | +| Summarization | `summarizeText` | 1 | Yes | +| Embeddings | `generateEmbedding` | 1 / 50,000 characters (`Math.ceil`) | Yes | | Wiki LLM / sync, memory write/heal | `wikiLlm`, `wikiSync`, `memoryWrite`, `memoryHeal` | 1 each | Yes | -| Agent turn (text) | cloud-agent `POST /agent/run` | 1 / turn (flat) | Yes | -| Live voice | cloud-agent `/agent/live` | 1 / 60s timer | Partial minute not billed | +| Agent turn (text) | cloud-agent `/agent/stream` (primary) and `POST /agent/run` (HTTP fallback) | 1 / internal tool-call loop iteration, max 5 | Yes (only credits actually spent this turn) | +| Live voice | cloud-agent `/agent/live` | 5 / 60s timer | Partial minute not billed | | Scheduler trigger | cloud-agent scheduler-trigger | 1 (deduped) | Yes | | `browser_action` tool | contextual | Voice: 1; Text: pre-billed (skipped) | See Browser Action Billing | -**Live voice connect gate:** a session requires a balance of **≥ 2** to start (enforced by both the client and the server). Billing runs on a 60-second timer, so a session shorter than the first tick is not billed. +**Live voice connect gate:** a session requires a balance of **≥ 5** to start (enforced by both the client and the server). Billing runs on a 60-second timer, so a session shorter than the first tick is not billed. --- diff --git a/docs/superpowers/plans/2026-06-29-mv3-bridge-phase1.md b/docs/superpowers/plans/2026-06-29-mv3-bridge-phase1.md deleted file mode 100644 index f1283ace..00000000 --- a/docs/superpowers/plans/2026-06-29-mv3-bridge-phase1.md +++ /dev/null @@ -1,3138 +0,0 @@ -# MV3 Browser Extension Bridge — Phase 1 (Read-Only) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship the read-only Wake-and-Connect bridge so a Clanker voice/text agent can instruct a paired MV3 desktop extension to read pages and return results. - -**Architecture:** Three async nodes rendezvous through Firestore (no direct Cloud Run socket-to-socket). A new `browser_action` ADK `FunctionTool` mints a bridge `sessionId`/`taskId`, writes a task doc, FCM-wakes the extension, and awaits the result via a per-task Firestore listener (voice) or a synchronous `Promise.race` (text). The extension wakes from `chrome.gcm`, mints a Firebase ID token via an offscreen document, opens the `/agent/browser` WebSocket, executes the Task DSL in a per-task injected content script, and returns the result. - -**Tech Stack:** Cloud Agent — TypeScript ESM, Express, `@google/adk`, `firebase-admin` (Firestore + Messaging), `ws`, Zod, `node:test`. Extension — TypeScript, `chrome.*` MV3 APIs, Firebase Web Auth SDK (offscreen), esbuild bundle, `node:test` + jsdom. - -**Scope:** Phase 1 only — `extract`, `summarize_visible_text`, `read_dom`, `open_tab`, `focus_tab`, `scroll`; pairing; billing; fail-closed errors; pause kill switch; host-permission grant. **Out of scope (separate plans):** stateful actions (`fill_field`/`click`), FCM approval cards + Expo Push, `haltedStepIndex` resume, proactive/Cloud Scheduler, multi-device. The DSL types in Task 2 include the Phase 2 stateful shapes (so the wire format is stable) but no Phase 1 task executes or approves them. - -**Conventions discovered in this repo (follow exactly):** -- cloud-agent is ESM (`"type": "module"`); all relative imports end in `.js`. Cross-package imports use repo-root relative paths, e.g. `import { clip } from '../../../shared/wiki-utils.js'`. -- cloud-agent tests use `node:test`: `import test from 'node:test'`, `import assert from 'node:assert/strict'`, dependency injection + mock objects, and `const { fn } = await import('./mod.js')`. -- cloud-agent test command builds first: `npm test` runs `tsc` then `node --test "dist/**/*.test.js"`. To run ONE file: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/.test.js`. -- Tools are ADK `FunctionTool` instances with a Zod `parameters` schema and an `execute(args): Promise` that returns a string (never throws to the model — catch and return an error string). See `cloud-agent/src/tools/tasks.ts`. - ---- - -## File Structure - -**Shared (repo root `shared/`, compiled into cloud-agent via its tsconfig `include: ["../shared"]`):** -- `shared/constants.ts` — `DESTRUCTIVE_ACTION_PATTERN` + `classifyActionLabel()` (single source of truth for both layers). -- `shared/dsl-types.ts` — `SingleAction`, `SequenceAction`, `TaskIntent`, `TaskResult`, `SessionDoc`, `TaskDoc`, `DeviceDoc`, error code union. -- `shared/dsl-schema.ts` — Zod schemas + `validateTaskIntent()` + `actionTier()` classifier. - -**Cloud Agent (`cloud-agent/src/`):** -- `services/firestoreSession.ts` — Firestore Admin read/write/listen helpers (DI-friendly). -- `services/fcmDispatcher.ts` — `wakeExtension()` via Admin messaging. -- `services/sessionBridge.ts` — in-memory per-instance session map. -- `tools/browserAction.ts` — the `browser_action` ADK `FunctionTool` + contextual billing. -- `handlers/wsBrowserAgentHandler.ts` — `/agent/browser` WS upgrade handler. -- Modify `services/agentCore.ts`, `services/liveToolAdapter.ts` — inject `browser_action`. -- Modify `handlers/wsLiveAgentHandler.ts` — expose `pauseBilling()`/`resumeBilling()`. -- Modify `index.ts` — `INSTANCE_ID`, `/agent/browser` upgrade, `POST /agent/browser/register-device`. - -**Extension (`extension/`):** greenfield npm package; layout per spec (`background/`, `offscreen/`, `content/`, `ui/`, `shared/`, `icons/`, `manifest.json`). - -**Infra:** -- `firestore.rules`, `firestore.indexes.json`, `firebase.json` (add `firestore` block). - ---- - -## PART A — Shared Contracts & Infrastructure - -### Task 1: Firestore rules, indexes, and firebase.json wiring - -**Files:** -- Create: `firestore.rules` -- Create: `firestore.indexes.json` -- Modify: `firebase.json` - -This is config (no unit test). The verification step is the Firestore emulator dry-run / `firebase deploy --dry-run`. - -- [ ] **Step 1: Write `firestore.rules`** - -``` -rules_version = '2'; -service cloud.firestore { - match /databases/{database}/documents { - - match /users/{uid}/sessions/{sessionId} { - allow read: if request.auth.uid == uid; - allow write: if false; // Admin SDK only - - match /tasks/{taskId} { - allow read: if request.auth.uid == uid; - allow write: if false; // Admin SDK only - } - - match /auth/{taskId} { - allow read: if request.auth.uid == uid; - // Phase 2 approval writes; created by Admin SDK only. - allow update: if request.auth.uid == uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['status', 'approvalToken', 'approvedAt']); - } - } - - match /users/{uid}/devices/{deviceId} { - allow read: if request.auth.uid == uid; - allow write: if false; // Admin SDK only (register-device upsert) - } - } -} -``` - -- [ ] **Step 2: Write `firestore.indexes.json`** - -`getActiveDevice` queries `devices` where `active == true AND isPaused == false` ordered by `lastSeenAt desc`. That composite query needs an index. - -```json -{ - "indexes": [ - { - "collectionGroup": "devices", - "queryScope": "COLLECTION", - "fields": [ - { "fieldPath": "active", "order": "ASCENDING" }, - { "fieldPath": "isPaused", "order": "ASCENDING" }, - { "fieldPath": "lastSeenAt", "order": "DESCENDING" } - ] - } - ], - "fieldOverrides": [ - { - "collectionGroup": "sessions", - "fieldPath": "expiresAt", - "ttl": true, - "indexes": [] - } - ] -} -``` - -- [ ] **Step 3: Add the `firestore` block to `firebase.json`** - -Add this key as a sibling of `"hosting"` and `"functions"` (keep existing content unchanged): - -```json - "firestore": { - "rules": "firestore.rules", - "indexes": "firestore.indexes.json" - } -``` - -- [ ] **Step 4: Verify rules compile** - -Run: `npx firebase deploy --only firestore:rules --dry-run` -Expected: `✔ rules file firestore.rules compiled successfully` (or auth prompt — if not logged in, run `firebase login` first via `! firebase login`). If `--dry-run` is unsupported in the installed CLI version, run `npx firebase emulators:exec --only firestore "true"` and expect a clean rules load with no parse error. - -- [ ] **Step 5: Commit** - -```bash -git add firestore.rules firestore.indexes.json firebase.json -git commit -m "feat(firestore): add bridge security rules, device index, TTL" -``` - -> **Manual infra prerequisites (record in PR description, not code):** enable Firestore Native mode in the Firebase project; configure TTL policy on `users/{uid}/sessions/{sessionId}.expiresAt`; obtain the FCM Sender ID (Project Settings → Cloud Messaging) for the extension. These are console actions, not committable. - ---- - -### Task 2: Shared DSL types - -**Files:** -- Create: `shared/dsl-types.ts` - -Pure type declarations — verification is `tsc`. No runtime test (types erase at runtime; the runtime validator is Task 3. - -- [ ] **Step 1: Write `shared/dsl-types.ts`** - -```typescript -// Canonical wire types for the browser bridge. Mirrored by extension/shared/dsl-types.ts. - -export type SingleAction = - | { type: 'open_tab'; url: string } - | { type: 'focus_tab'; host: string } - | { type: 'extract'; selector: string; label?: string } - | { type: 'summarize_visible_text'; filter?: 'no_nav' | 'no_ads' | 'all' } - | { type: 'read_dom'; selector: string } - | { type: 'scroll'; direction: 'up' | 'down'; pixels?: number } - // Phase 2 (wire-stable, never executed in Phase 1): - | { type: 'fill_field'; selector: string; value: string; tier: 'stateful' } - | { type: 'click'; selector: string; label?: string; tier: 'stateful' } - -export interface SequenceAction { - type: 'sequence' - steps: SingleAction[] // no nested sequences -} - -export interface TaskIntent { - version: '1' - taskId: string - sessionId: string - requiresAuth: boolean - actionSummary: string - action: SingleAction | SequenceAction -} - -export type BridgeErrorCode = - | 'SELECTOR_NOT_FOUND' - | 'HOST_NOT_ALLOWED' - | 'HOST_PERMISSION_REQUIRED' - | 'EXTENSION_OFFLINE' - | 'AUTH_TIMEOUT' - | 'EXECUTION_ERROR' - | 'EXECUTION_TIMEOUT' - -export interface TaskResult { - taskId: string - status: 'complete' | 'failed' | 'aborted' - data: Record // keyed by `label` from extract steps - activeUrl: string - error?: { - code: BridgeErrorCode - message: string - failedAction: SingleAction - } -} - -export type SessionStatus = 'pending' | 'routing' | 'pending_auth' | 'closed' | 'aborted' -export type TaskStatus = 'pending' | 'executing' | 'awaiting_auth' | 'complete' | 'failed' | 'aborted' - -export interface SessionDoc { - status: SessionStatus - trigger: 'voice' | 'text' | 'scheduler' - voiceInstanceId: string - browserInstanceId?: string | null - browserConnectedAt?: unknown | null // Firestore Timestamp - createdAt: unknown - expiresAt: unknown -} - -export interface TaskDoc { - status: TaskStatus - intent: TaskIntent - result: TaskResult | null - error: TaskResult['error'] | null - authRequired: boolean - haltedStepIndex: number | null - createdAt: unknown - updatedAt: unknown -} - -export interface DeviceDoc { - deviceId: string - fcmToken: string - deviceName: string - registeredAt?: unknown - lastSeenAt?: unknown - active: boolean - isPaused: boolean -} -``` - -- [ ] **Step 2: Verify it typechecks under cloud-agent** - -Run: `cd cloud-agent && npx tsc --noEmit` -Expected: no errors (the file is picked up via `include: ["../shared"]`). - -- [ ] **Step 3: Commit** - -```bash -git add shared/dsl-types.ts -git commit -m "feat(shared): add browser bridge DSL wire types" -``` - ---- - -### Task 3: Shared destructive-action constant + label classifier - -**Files:** -- Create: `shared/constants.ts` -- Test: `shared/constants.test.ts` - -> **Test placement note:** cloud-agent's tsconfig `exclude`s `../shared/**/*.test.ts` from the build, so `shared/constants.test.ts` will NOT run under `cloud-agent npm test`. Run shared tests directly with tsx: `node --import tsx/esm --test shared/constants.test.ts`. (tsx is already available as a cloud-agent dependency; invoke from repo root via `npx tsx` if not on PATH — use `node --import tsx/esm`.) - -- [ ] **Step 1: Write the failing test** - -```typescript -// shared/constants.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { DESTRUCTIVE_ACTION_PATTERN, classifyActionLabel } from './constants.js' - -test('pattern matches destructive verbs case-insensitively', () => { - for (const s of ['Submit Payment', 'DELETE account', 'pay now', 'Confirm order', 'Cancel subscription']) { - assert.equal(DESTRUCTIVE_ACTION_PATTERN.test(s), true, s) - } -}) - -test('pattern ignores benign labels', () => { - for (const s of ['Read more', 'Show details', 'Next page', 'order_total']) { - assert.equal(DESTRUCTIVE_ACTION_PATTERN.test(s), false, s) - } -}) - -test('classifyActionLabel returns requires_auth for destructive text', () => { - assert.equal(classifyActionLabel('Submit Payment'), 'requires_auth') - assert.equal(classifyActionLabel('Read article'), 'safe') - assert.equal(classifyActionLabel(undefined), 'safe') -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `node --import tsx/esm --test shared/constants.test.ts` -Expected: FAIL — cannot find module `./constants.js`. - -- [ ] **Step 3: Write `shared/constants.ts`** - -```typescript -// Single source of truth for the two-layer destructive-action classifier. -// Imported by cloud-agent (Layer 1) and the extension content script (Layer 2). -export const DESTRUCTIVE_ACTION_PATTERN = - /submit|delete|pay|confirm|send|checkout|transfer|remove|cancel subscription/i - -export function classifyActionLabel(label: string | undefined | null): 'safe' | 'requires_auth' { - if (!label) return 'safe' - return DESTRUCTIVE_ACTION_PATTERN.test(label) ? 'requires_auth' : 'safe' -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `node --import tsx/esm --test shared/constants.test.ts` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add shared/constants.ts shared/constants.test.ts -git commit -m "feat(shared): destructive-action pattern + label classifier" -``` - ---- - -### Task 4: Shared DSL Zod schema + tier classifier - -**Files:** -- Create: `shared/dsl-schema.ts` -- Test: `shared/dsl-schema.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// shared/dsl-schema.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { taskIntentSchema, validateTaskIntent, actionTier } from './dsl-schema.js' - -const validReadOnly = { - version: '1', taskId: 't1', sessionId: 's1', requiresAuth: false, - actionSummary: 'Summarize', action: { type: 'summarize_visible_text', filter: 'no_nav' }, -} - -test('accepts a valid read-only intent', () => { - assert.equal(taskIntentSchema.safeParse(validReadOnly).success, true) -}) - -test('rejects unknown action type', () => { - const bad = { ...validReadOnly, action: { type: 'wipe_disk' } } - assert.equal(taskIntentSchema.safeParse(bad).success, false) -}) - -test('rejects nested sequences', () => { - const bad = { - ...validReadOnly, - action: { type: 'sequence', steps: [{ type: 'sequence', steps: [] }] }, - } - assert.equal(taskIntentSchema.safeParse(bad).success, false) -}) - -test('validateTaskIntent returns typed value or throws', () => { - assert.equal(validateTaskIntent(validReadOnly).taskId, 't1') - assert.throws(() => validateTaskIntent({ version: '1' })) -}) - -test('actionTier classifies primitives', () => { - assert.equal(actionTier({ type: 'extract', selector: '.x' }), 'read_only') - assert.equal(actionTier({ type: 'open_tab', url: 'https://a.com' }), 'navigation') - assert.equal(actionTier({ type: 'click', selector: '#b', tier: 'stateful' }), 'stateful') -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `node --import tsx/esm --test shared/dsl-schema.test.ts` -Expected: FAIL — cannot find `./dsl-schema.js`. - -- [ ] **Step 3: Write `shared/dsl-schema.ts`** - -```typescript -import { z } from 'zod' -import type { SingleAction } from './dsl-types.js' - -const singleActionSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('open_tab'), url: z.string().url() }), - z.object({ type: z.literal('focus_tab'), host: z.string().min(1) }), - z.object({ type: z.literal('extract'), selector: z.string().min(1), label: z.string().optional() }), - z.object({ type: z.literal('summarize_visible_text'), filter: z.enum(['no_nav', 'no_ads', 'all']).optional() }), - z.object({ type: z.literal('read_dom'), selector: z.string().min(1) }), - z.object({ type: z.literal('scroll'), direction: z.enum(['up', 'down']), pixels: z.number().int().positive().optional() }), - z.object({ type: z.literal('fill_field'), selector: z.string().min(1), value: z.string(), tier: z.literal('stateful') }), - z.object({ type: z.literal('click'), selector: z.string().min(1), label: z.string().optional(), tier: z.literal('stateful') }), -]) - -const sequenceActionSchema = z.object({ - type: z.literal('sequence'), - steps: z.array(singleActionSchema).min(1), // singleActionSchema has no 'sequence' member → nesting rejected -}) - -export const taskIntentSchema = z.object({ - version: z.literal('1'), - taskId: z.string().min(1), - sessionId: z.string().min(1), - requiresAuth: z.boolean(), - actionSummary: z.string(), - action: z.union([singleActionSchema, sequenceActionSchema]), -}) - -export type ValidatedTaskIntent = z.infer - -export function validateTaskIntent(input: unknown): ValidatedTaskIntent { - return taskIntentSchema.parse(input) -} - -const READ_ONLY = new Set(['extract', 'summarize_visible_text', 'read_dom']) -const NAVIGATION = new Set(['open_tab', 'focus_tab', 'scroll']) - -export function actionTier(action: SingleAction): 'read_only' | 'navigation' | 'stateful' { - if (READ_ONLY.has(action.type)) return 'read_only' - if (NAVIGATION.has(action.type)) return 'navigation' - return 'stateful' -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `node --import tsx/esm --test shared/dsl-schema.test.ts` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add shared/dsl-schema.ts shared/dsl-schema.test.ts -git commit -m "feat(shared): DSL zod schema + tier classifier" -``` - ---- - -## PART B — Cloud Agent Coordinator - -### Task 5: `firestoreSession.ts` — Firestore Admin helpers - -**Files:** -- Create: `cloud-agent/src/services/firestoreSession.ts` -- Test: `cloud-agent/src/services/firestoreSession.test.ts` - -The module takes a `Firestore`-like dependency so tests inject a fake. In production it defaults to `admin.firestore()`. - -- [ ] **Step 1: Write the failing test** - -```typescript -// cloud-agent/src/services/firestoreSession.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' - -// Minimal in-memory Firestore double. Path string → doc data. -function makeFakeDb() { - const store = new Map>() - function docRef(path: string) { - return { - path, - async set(data: Record, opts?: { merge?: boolean }) { - store.set(path, opts?.merge ? { ...(store.get(path) ?? {}), ...data } : data) - }, - async get() { - const data = store.get(path) - return { exists: data !== undefined, data: () => data } - }, - async update(data: Record) { - store.set(path, { ...(store.get(path) ?? {}), ...data }) - }, - } - } - const db = { - doc: (path: string) => docRef(path), - collection: (path: string) => ({ - where() { return this }, - orderBy() { return this }, - limit() { return this }, - async get() { - // return device docs under this collection path, filtered active && !paused - const docs = [...store.entries()] - .filter(([k, v]) => k.startsWith(path + '/') && v.active === true && v.isPaused === false) - .sort((a, b) => Number(b[1].lastSeenAt ?? 0) - Number(a[1].lastSeenAt ?? 0)) - .map(([k, v]) => ({ id: k.split('/').pop(), data: () => v })) - return { empty: docs.length === 0, docs } - }, - }), - } - return { db, store } -} - -const { createFirestoreSession } = await import('./firestoreSession.js') - -test('createSession + getSession round-trip', async () => { - const { db } = makeFakeDb() - const fs = createFirestoreSession(db as never) - await fs.createSession('u1', 's1', { status: 'pending', trigger: 'voice', voiceInstanceId: 'i1' }) - const s = await fs.getSession('u1', 's1') - assert.equal(s.status, 'pending') - assert.equal(s.voiceInstanceId, 'i1') -}) - -test('markBrowserConnected sets routing + browserInstanceId and task executing', async () => { - const { db } = makeFakeDb() - const fs = createFirestoreSession(db as never) - await fs.createSession('u1', 's1', { status: 'pending', trigger: 'voice', voiceInstanceId: 'i1' }) - await fs.writeTask('u1', 's1', 't1', { - version: '1', taskId: 't1', sessionId: 's1', requiresAuth: false, - actionSummary: 'x', action: { type: 'read_dom', selector: 'body' }, - }) - await fs.markBrowserConnected('u1', 's1', 'i2', 't1') - const s = await fs.getSession('u1', 's1') - const t = await fs.getTask('u1', 's1', 't1') - assert.equal(s.status, 'routing') - assert.equal(s.browserInstanceId, 'i2') - assert.notEqual(s.browserConnectedAt, null) - assert.equal(t.status, 'executing') -}) - -test('getActiveDevice returns null when none active', async () => { - const { db } = makeFakeDb() - const fs = createFirestoreSession(db as never) - assert.equal(await fs.getActiveDevice('u1'), null) -}) - -test('getActiveDevice skips paused devices', async () => { - const { db, store } = makeFakeDb() - store.set('users/u1/devices/d1', { fcmToken: 'tok', deviceName: 'Mac', active: true, isPaused: true, lastSeenAt: 5 }) - const fs = createFirestoreSession(db as never) - assert.equal(await fs.getActiveDevice('u1'), null) -}) - -test('getActiveDevice returns most-recent active unpaused device', async () => { - const { db, store } = makeFakeDb() - store.set('users/u1/devices/d1', { fcmToken: 'old', deviceName: 'Mac', active: true, isPaused: false, lastSeenAt: 1 }) - store.set('users/u1/devices/d2', { fcmToken: 'new', deviceName: 'PC', active: true, isPaused: false, lastSeenAt: 9 }) - const fs = createFirestoreSession(db as never) - const d = await fs.getActiveDevice('u1') - assert.equal(d?.fcmToken, 'new') - assert.equal(d?.deviceId, 'd2') -}) - -test('writeTaskResult sets terminal status + result', async () => { - const { db } = makeFakeDb() - const fs = createFirestoreSession(db as never) - await fs.writeTask('u1', 's1', 't1', { - version: '1', taskId: 't1', sessionId: 's1', requiresAuth: false, - actionSummary: 'x', action: { type: 'read_dom', selector: 'body' }, - }) - await fs.writeTaskResult('u1', 's1', 't1', { taskId: 't1', status: 'complete', data: { a: 'b' }, activeUrl: 'https://x' }) - const t = await fs.getTask('u1', 's1', 't1') - assert.equal(t.status, 'complete') - assert.deepEqual(t.result?.data, { a: 'b' }) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/services/firestoreSession.test.js` -Expected: FAIL — cannot find `./firestoreSession.js`. - -- [ ] **Step 3: Write `cloud-agent/src/services/firestoreSession.ts`** - -```typescript -import admin from 'firebase-admin' -import type { TaskIntent, TaskResult, SessionDoc, TaskDoc, DeviceDoc } from '../../../shared/dsl-types.js' - -// Structural subset of firebase-admin Firestore we use. Lets tests inject a fake. -export interface FirestoreLike { - doc(path: string): { - set(data: Record, opts?: { merge?: boolean }): Promise - get(): Promise<{ exists: boolean; data(): Record | undefined }> - update(data: Record): Promise - onSnapshot?(cb: (snap: { exists: boolean; data(): Record | undefined }) => void): () => void - } - collection(path: string): { - where(field: string, op: string, value: unknown): unknown - orderBy(field: string, dir: 'asc' | 'desc'): unknown - limit(n: number): unknown - get(): Promise<{ empty: boolean; docs: Array<{ id: string; data(): Record }> }> - } -} - -export interface SessionMeta { - status: SessionDoc['status'] - trigger: SessionDoc['trigger'] - voiceInstanceId: string -} - -const SESSION_TTL_MS = 30 * 60 * 1000 - -function now() { return admin.firestore?.Timestamp ? admin.firestore.Timestamp.now() : (Date.now() as unknown) } -function ttl() { - return admin.firestore?.Timestamp - ? admin.firestore.Timestamp.fromMillis(Date.now() + SESSION_TTL_MS) - : (Date.now() + SESSION_TTL_MS as unknown) -} - -export function createFirestoreSession(db: FirestoreLike) { - const sessionPath = (uid: string, sid: string) => `users/${uid}/sessions/${sid}` - const taskPath = (uid: string, sid: string, tid: string) => `users/${uid}/sessions/${sid}/tasks/${tid}` - const devicesPath = (uid: string) => `users/${uid}/devices` - - return { - async getActiveDevice(uid: string): Promise<{ deviceId: string; fcmToken: string; deviceName: string } | null> { - const q = db.collection(devicesPath(uid)) - .where('active', '==', true) - .where('isPaused', '==', false) as ReturnType - const snap = await q.orderBy('lastSeenAt', 'desc').limit(1).get() as Awaited['get']>> - if (snap.empty) return null - const d = snap.docs[0] - const data = d.data() as DeviceDoc - return { deviceId: d.id, fcmToken: data.fcmToken, deviceName: data.deviceName } - }, - - async createSession(uid: string, sid: string, meta: SessionMeta): Promise { - await db.doc(sessionPath(uid, sid)).set({ - status: meta.status, trigger: meta.trigger, voiceInstanceId: meta.voiceInstanceId, - browserInstanceId: null, browserConnectedAt: null, createdAt: now(), expiresAt: ttl(), - }) - }, - - async getSession(uid: string, sid: string): Promise { - const doc = await db.doc(sessionPath(uid, sid)).get() - if (!doc.exists) throw new Error('SESSION_NOT_FOUND') - return doc.data() as SessionDoc - }, - - async markBrowserConnected(uid: string, sid: string, browserInstanceId: string, taskId: string): Promise { - await db.doc(sessionPath(uid, sid)).update({ - status: 'routing', browserInstanceId, browserConnectedAt: now(), - }) - await db.doc(taskPath(uid, sid, taskId)).update({ status: 'executing', updatedAt: now() }) - }, - - async closeSession(uid: string, sid: string, status: 'closed' | 'aborted'): Promise { - await db.doc(sessionPath(uid, sid)).update({ status }) - }, - - async writeTask(uid: string, sid: string, tid: string, intent: TaskIntent): Promise { - await db.doc(taskPath(uid, sid, tid)).set({ - status: 'pending', intent, result: null, error: null, - authRequired: intent.requiresAuth, haltedStepIndex: null, createdAt: now(), updatedAt: now(), - }) - }, - - async getTask(uid: string, sid: string, tid: string): Promise { - const doc = await db.doc(taskPath(uid, sid, tid)).get() - if (!doc.exists) throw new Error('TASK_NOT_FOUND') - return doc.data() as TaskDoc - }, - - async writeTaskResult(uid: string, sid: string, tid: string, result: TaskResult): Promise { - await db.doc(taskPath(uid, sid, tid)).update({ - status: result.status, result, error: result.error ?? null, updatedAt: now(), - }) - }, - - // Per-task listener. Returns unsubscribe. Used by the voice-side instance. - watchTask(uid: string, sid: string, tid: string, cb: (task: TaskDoc) => void): () => void { - const ref = db.doc(taskPath(uid, sid, tid)) - if (!ref.onSnapshot) throw new Error('watchTask requires onSnapshot support') - return ref.onSnapshot((snap) => { - if (snap.exists) cb(snap.data() as unknown as TaskDoc) - }) - }, - } -} - -export type FirestoreSession = ReturnType - -export function defaultFirestoreSession(): FirestoreSession { - return createFirestoreSession(admin.firestore() as unknown as FirestoreLike) -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/services/firestoreSession.test.js` -Expected: PASS (6 tests). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/services/firestoreSession.ts cloud-agent/src/services/firestoreSession.test.ts -git commit -m "feat(cloud-agent): firestoreSession admin helpers" -``` - ---- - -### Task 6: `fcmDispatcher.ts` — silent wake push - -**Files:** -- Create: `cloud-agent/src/services/fcmDispatcher.ts` -- Test: `cloud-agent/src/services/fcmDispatcher.test.ts` - -Phase 1 needs only `wakeExtension`. (Expo Push helpers are Phase 2 — omitted here.) - -- [ ] **Step 1: Write the failing test** - -```typescript -// cloud-agent/src/services/fcmDispatcher.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' - -const { createFcmDispatcher } = await import('./fcmDispatcher.js') - -test('wakeExtension sends WAKE_AND_CONNECT data payload to the token', async () => { - const sent: unknown[] = [] - const messaging = { send: async (msg: unknown) => { sent.push(msg); return 'msg-id' } } - const fcm = createFcmDispatcher(messaging as never) - await fcm.wakeExtension('tok-123', 's1', 't1') - assert.equal(sent.length, 1) - assert.deepEqual(sent[0], { - token: 'tok-123', - data: { type: 'WAKE_AND_CONNECT', sessionId: 's1', taskId: 't1', resume: 'false' }, - }) -}) - -test('wakeExtension marks resume=true when requested', async () => { - const sent: Array<{ data: { resume: string } }> = [] - const messaging = { send: async (msg: never) => { sent.push(msg); return 'm' } } - const fcm = createFcmDispatcher(messaging as never) - await fcm.wakeExtension('tok', 's', 't', true) - assert.equal(sent[0].data.resume, 'true') -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/services/fcmDispatcher.test.js` -Expected: FAIL — cannot find `./fcmDispatcher.js`. - -- [ ] **Step 3: Write `cloud-agent/src/services/fcmDispatcher.ts`** - -```typescript -import admin from 'firebase-admin' - -// FCM data messages require all values to be strings. -export interface MessagingLike { - send(message: { token: string; data: Record }): Promise -} - -export function createFcmDispatcher(messaging: MessagingLike) { - return { - async wakeExtension(fcmToken: string, sessionId: string, taskId: string, resume = false): Promise { - await messaging.send({ - token: fcmToken, - data: { type: 'WAKE_AND_CONNECT', sessionId, taskId, resume: String(resume) }, - }) - }, - } -} - -export type FcmDispatcher = ReturnType - -export function defaultFcmDispatcher(): FcmDispatcher { - return createFcmDispatcher(admin.messaging() as unknown as MessagingLike) -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/services/fcmDispatcher.test.js` -Expected: PASS (2 tests). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/services/fcmDispatcher.ts cloud-agent/src/services/fcmDispatcher.test.ts -git commit -m "feat(cloud-agent): fcmDispatcher wakeExtension" -``` - ---- - -### Task 7: `sessionBridge.ts` — in-memory per-instance map - -**Files:** -- Create: `cloud-agent/src/services/sessionBridge.ts` -- Test: `cloud-agent/src/services/sessionBridge.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// cloud-agent/src/services/sessionBridge.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' - -const { createSessionBridge } = await import('./sessionBridge.js') - -const fakeWs = () => ({ readyState: 1 }) as unknown as import('ws').WebSocket - -test('registerBrowser then getSession returns the browserWs', () => { - const b = createSessionBridge() - const ws = fakeWs() - b.registerBrowser('u1', 's1', ws) - assert.equal(b.getSession('u1', 's1')?.browserWs, ws) -}) - -test('voice + browser co-registration on same key', () => { - const b = createSessionBridge() - const v = fakeWs(); const br = fakeWs() - b.registerVoice('u1', 's1', v) - b.registerBrowser('u1', 's1', br) - const s = b.getSession('u1', 's1') - assert.equal(s?.voiceWs, v) - assert.equal(s?.browserWs, br) -}) - -test('deregister removes the entry', () => { - const b = createSessionBridge() - b.registerBrowser('u1', 's1', fakeWs()) - b.deregister('u1', 's1') - assert.equal(b.getSession('u1', 's1'), undefined) -}) - -test('different uids/sessions are isolated', () => { - const b = createSessionBridge() - b.registerBrowser('u1', 's1', fakeWs()) - assert.equal(b.getSession('u2', 's1'), undefined) - assert.equal(b.getSession('u1', 's2'), undefined) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/services/sessionBridge.test.js` -Expected: FAIL — cannot find `./sessionBridge.js`. - -- [ ] **Step 3: Write `cloud-agent/src/services/sessionBridge.ts`** - -```typescript -import type { WebSocket } from 'ws' - -export interface SessionState { - sessionId: string - voiceWs: WebSocket | null - browserWs: WebSocket | null - firestoreUnsub: (() => void) | null -} - -const key = (uid: string, sessionId: string) => `${uid}:${sessionId}` - -export function createSessionBridge() { - const map = new Map() - function ensure(uid: string, sessionId: string): SessionState { - const k = key(uid, sessionId) - let s = map.get(k) - if (!s) { s = { sessionId, voiceWs: null, browserWs: null, firestoreUnsub: null }; map.set(k, s) } - return s - } - return { - registerBrowser(uid: string, sessionId: string, ws: WebSocket): void { ensure(uid, sessionId).browserWs = ws }, - registerVoice(uid: string, sessionId: string, ws: WebSocket): void { ensure(uid, sessionId).voiceWs = ws }, - getSession(uid: string, sessionId: string): SessionState | undefined { return map.get(key(uid, sessionId)) }, - deregister(uid: string, sessionId: string): void { - const s = map.get(key(uid, sessionId)) - try { s?.firestoreUnsub?.() } catch { /* ignore */ } - map.delete(key(uid, sessionId)) - }, - } -} - -export type SessionBridge = ReturnType - -// Module-level singleton — one map per Cloud Run instance. -export const sessionBridge: SessionBridge = createSessionBridge() -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/services/sessionBridge.test.js` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/services/sessionBridge.ts cloud-agent/src/services/sessionBridge.test.ts -git commit -m "feat(cloud-agent): in-memory sessionBridge" -``` - ---- - -### Task 8: `INSTANCE_ID` per-container constant - -**Files:** -- Create: `cloud-agent/src/services/instanceId.ts` -- Modify: `cloud-agent/src/index.ts` - -Define `INSTANCE_ID` in its own module (not `index.ts`). Both `index.ts` and `wsLiveAgentHandler.ts` import it; putting it in `index.ts` would create an import cycle (`index.ts` imports the handler, the handler imports `index.ts`). - -- [ ] **Step 1: Write `cloud-agent/src/services/instanceId.ts`** - -```typescript -// Per-container identity. K_REVISION identifies a deployment revision, not a -// container; this UUID is generated once per process for true per-instance tracking. -export const INSTANCE_ID = crypto.randomUUID() -``` - -- [ ] **Step 2: Re-export from `index.ts` for callers that import it from there** - -Add to `index.ts` imports/exports: - -```typescript -export { INSTANCE_ID } from './services/instanceId.js' -``` - -- [ ] **Step 3: Verify it compiles** - -Run: `cd cloud-agent && npx tsc --noEmit` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add cloud-agent/src/services/instanceId.ts cloud-agent/src/index.ts -git commit -m "feat(cloud-agent): per-container INSTANCE_ID" -``` - ---- - -### Task 9: `POST /agent/browser/register-device` endpoint - -**Files:** -- Modify: `cloud-agent/src/index.ts` -- Test: `cloud-agent/src/index.test.ts` (append cases; if the existing file does not exist as a supertest harness, create `cloud-agent/src/registerDevice.test.ts` using the createApp factory) - -The endpoint upserts `users/{uid}/devices/{deviceId}` via Admin SDK. To keep it testable, accept an injectable device-writer in `AppOptions`. - -- [ ] **Step 1: Write the failing test** - -```typescript -// cloud-agent/src/registerDevice.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import type { AddressInfo } from 'node:net' - -const { createApp } = await import('./index.js') - -function startApp(overrides: Record = {}) { - const writes: unknown[] = [] - const app = createApp({ - verifyToken: async () => ({ uid: 'fb-uid-1' }), - db: {} as never, - runAgentFn: async () => ({ reply: 'x', toolCalls: [] }), - upsertDevice: async (uid: string, body: unknown) => { writes.push({ uid, body }) }, - ...overrides, - } as never) - const server = app.listen(0) - const port = (server.address() as AddressInfo).port - return { server, port, writes } -} - -test('register-device rejects unauthenticated requests', async () => { - const { server, port } = startApp() - const res = await fetch(`http://127.0.0.1:${port}/agent/browser/register-device`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ fcmToken: 't', deviceId: 'd', deviceName: 'Mac' }), - }) - assert.equal(res.status, 401) - server.close() -}) - -test('register-device upserts on valid body', async () => { - const { server, port, writes } = startApp() - const res = await fetch(`http://127.0.0.1:${port}/agent/browser/register-device`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer good' }, - body: JSON.stringify({ fcmToken: 'tok', deviceId: 'dev-1', deviceName: 'Home Mac — Chrome', isPaused: false }), - }) - assert.equal(res.status, 200) - assert.deepEqual(await res.json(), { ok: true }) - assert.equal((writes[0] as { uid: string }).uid, 'fb-uid-1') - server.close() -}) - -test('register-device 400 on missing fields', async () => { - const { server, port } = startApp() - const res = await fetch(`http://127.0.0.1:${port}/agent/browser/register-device`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: 'Bearer good' }, - body: JSON.stringify({ deviceId: 'd' }), - }) - assert.equal(res.status, 400) - server.close() -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/registerDevice.test.js` -Expected: FAIL — `upsertDevice` not in `AppOptions` / route 404. - -- [ ] **Step 3: Add `upsertDevice` to `AppOptions` and implement the route** - -In `index.ts`, extend the interface: - -```typescript -export interface AppOptions { - verifyToken: (token: string) => Promise<{ uid: string }> - db: DrizzleClient - runAgentFn: (params: RunAgentParams) => Promise<{ reply: string; toolCalls: string[]; groundingMetadata?: GroundingMetadata }> - creditService?: CreditService - wsHandlerOptions?: Partial - wsLiveHandlerOptions?: Partial - upsertDevice?: (uid: string, body: { fcmToken: string; deviceId: string; deviceName: string; isPaused?: boolean }) => Promise -} -``` - -Add a default Admin-SDK implementation and the route inside `createApp` (after the `/agent/run` handler, before `return app`): - -```typescript - const upsertDevice = options.upsertDevice ?? (async (uid, body) => { - const fs = admin.firestore() - await fs.doc(`users/${uid}/devices/${body.deviceId}`).set({ - fcmToken: body.fcmToken, - deviceName: body.deviceName, - active: true, - isPaused: body.isPaused ?? false, - lastSeenAt: admin.firestore.Timestamp.now(), - registeredAt: admin.firestore.Timestamp.now(), - }, { merge: true }) - }) - - app.post('/agent/browser/register-device', requireAuth, async (req: Request & { uid?: string }, res: Response): Promise => { - const parsed = z.object({ - fcmToken: z.string().min(1), - deviceId: z.string().min(1), - deviceName: z.string().min(1), - isPaused: z.boolean().optional(), - }).safeParse(req.body) - if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return } - try { - await upsertDevice(req.uid!, parsed.data) - res.json({ ok: true }) - } catch (err) { - console.error('register-device error:', err) - res.status(500).json({ error: 'Internal server error' }) - } - }) -``` - -> Note: `registeredAt` uses `merge: true` so re-registration keeps the earliest value only if you guard it; for Phase 1, overwriting on each upsert is acceptable. The `isPaused` field is also written here when the side panel toggles pause (same endpoint, see Task 25). - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/registerDevice.test.js` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/index.ts cloud-agent/src/registerDevice.test.ts -git commit -m "feat(cloud-agent): POST /agent/browser/register-device" -``` - ---- - -### Task 10: `wsBrowserAgentHandler.ts` — `/agent/browser` protocol - -**Files:** -- Create: `cloud-agent/src/handlers/wsBrowserAgentHandler.ts` -- Test: `cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts` - -The handler: enforce 5s auth-frame timeout (close 4001); verify idToken; validate deviceId; `markBrowserConnected`; send `session_ready`; register browserWs; dispatch the pending task; on `task_result`/`task_error` write to Firestore; on close deregister. - -- [ ] **Step 1: Write the failing test (use a fake WebSocket)** - -```typescript -// cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' - -const { handleBrowserWsUpgrade } = await import('./wsBrowserAgentHandler.js') - -class FakeWs extends EventEmitter { - readyState = 1 - sent: string[] = [] - closed: { code?: number; reason?: string } | null = null - send(s: string) { this.sent.push(s) } - close(code?: number, reason?: string) { this.readyState = 3; this.closed = { code, reason } } - emitJson(obj: unknown) { this.emit('message', Buffer.from(JSON.stringify(obj))) } -} - -function deps(over: Record = {}) { - const calls: Record = { mark: [], result: [] } - return { - calls, - options: { - verifyToken: async () => ({ uid: 'fb-uid' }), - resolveUserId: async () => 'u1', - firestoreSession: { - getSession: async () => ({ status: 'pending' }), - getTask: async () => ({ status: 'pending', intent: { version: '1', taskId: 't1', sessionId: 's1', requiresAuth: false, actionSummary: 'x', action: { type: 'read_dom', selector: 'body' } } }), - markBrowserConnected: async (...a: unknown[]) => { calls.mark.push(a) }, - writeTaskResult: async (...a: unknown[]) => { calls.result.push(a) }, - closeSession: async () => {}, - }, - validateDevice: async () => true, - instanceId: 'i-test', - authTimeoutMs: 50, - ...over, - }, - } -} - -test('closes 4001 when no auth frame within timeout', async () => { - const ws = new FakeWs() - const { options } = deps() - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - await new Promise((r) => setTimeout(r, 80)) - assert.equal(ws.closed?.code, 4001) -}) - -test('auth → markBrowserConnected → session_ready → dispatch task', async () => { - const ws = new FakeWs() - const { options, calls } = deps() - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - ws.emitJson({ type: 'auth', idToken: 'tok', sessionId: 's1', deviceId: 'd1' }) - await new Promise((r) => setTimeout(r, 20)) - assert.equal(calls.mark.length, 1) - const types = ws.sent.map((s) => JSON.parse(s).type) - assert.ok(types.includes('session_ready')) - assert.ok(types.includes('task')) -}) - -test('task_result frame is persisted via writeTaskResult', async () => { - const ws = new FakeWs() - const { options, calls } = deps() - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - ws.emitJson({ type: 'auth', idToken: 'tok', sessionId: 's1', deviceId: 'd1' }) - await new Promise((r) => setTimeout(r, 20)) - ws.emitJson({ type: 'task_result', taskId: 't1', data: { k: 'v' }, activeUrl: 'https://x' }) - await new Promise((r) => setTimeout(r, 20)) - assert.equal(calls.result.length, 1) - const result = (calls.result[0] as unknown[])[3] as { status: string; data: Record } - assert.equal(result.status, 'complete') - assert.deepEqual(result.data, { k: 'v' }) -}) - -test('rejects auth when deviceId invalid', async () => { - const ws = new FakeWs() - const { options } = deps({ validateDevice: async () => false }) - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - ws.emitJson({ type: 'auth', idToken: 'tok', sessionId: 's1', deviceId: 'bad' }) - await new Promise((r) => setTimeout(r, 20)) - assert.equal(ws.closed?.code, 4001) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/handlers/wsBrowserAgentHandler.test.js` -Expected: FAIL — cannot find `./wsBrowserAgentHandler.js`. - -- [ ] **Step 3: Write `cloud-agent/src/handlers/wsBrowserAgentHandler.ts`** - -```typescript -import type { WebSocket } from 'ws' -import type { IncomingMessage } from 'http' -import admin from 'firebase-admin' -import { z } from 'zod' -import type { FirestoreSession } from '../services/firestoreSession.js' -import { sessionBridge } from '../services/sessionBridge.js' -import type { TaskResult, SingleAction, BridgeErrorCode } from '../../../shared/dsl-types.js' - -const browserAuthSchema = z.object({ - type: z.literal('auth'), - idToken: z.string().min(1), - sessionId: z.string().uuid(), - deviceId: z.string().min(1), -}) - -const resultFrameSchema = z.object({ - type: z.literal('task_result'), - taskId: z.string(), - data: z.record(z.string()), - activeUrl: z.string(), -}) - -const errorFrameSchema = z.object({ - type: z.literal('task_error'), - taskId: z.string(), - code: z.string(), - message: z.string(), - failedAction: z.unknown(), -}) - -export interface BrowserWsOptions { - firestoreSession: FirestoreSession - verifyToken?: (token: string) => Promise<{ uid: string }> - resolveUserId?: (firebaseUid: string) => Promise - validateDevice?: (uid: string, deviceId: string) => Promise - instanceId: string - authTimeoutMs?: number -} - -export function handleBrowserWsUpgrade( - ws: WebSocket, - _req: IncomingMessage, - options: BrowserWsOptions, -): void { - const verifyToken = options.verifyToken ?? - ((t: string) => admin.auth().verifyIdToken(t).then((d) => ({ uid: d.uid }))) - const resolveUserId = options.resolveUserId ?? (async (u: string) => u) - const validateDevice = options.validateDevice ?? (async () => true) - const fs = options.firestoreSession - const authTimeoutMs = options.authTimeoutMs ?? 5000 - - let authed = false - let uid: string | null = null - let sessionId: string | null = null - - const authTimer = setTimeout(() => { - if (!authed && ws.readyState === ws.OPEN) ws.close(4001, 'Auth timeout') - }, authTimeoutMs) - - async function onAuth(raw: unknown): Promise { - const parsed = browserAuthSchema.safeParse(raw) - if (!parsed.success) { ws.close(4001, 'Invalid auth frame'); return } - const { idToken, sessionId: sid, deviceId } = parsed.data - let fbUid: string - try { fbUid = (await verifyToken(idToken)).uid } catch { ws.close(4001, 'Token verification failed'); return } - const resolved = await resolveUserId(fbUid) - if (!resolved) { ws.close(4001, 'User not found'); return } - if (!(await validateDevice(resolved, deviceId))) { ws.close(4001, 'Unknown device'); return } - - const session = await fs.getSession(resolved, sid) - if (session.status === 'closed') { ws.close(4001, 'Session closed'); return } - - uid = resolved; sessionId = sid; authed = true - clearTimeout(authTimer) - - // The extension echoes only sessionId in its auth frame. A Phase 1 bridge - // session has exactly one task, so read it from Firestore. - const pendingTask = await fs.getFirstTask(uid, sid) - if (!pendingTask) { ws.close(4001, 'No pending task'); return } - - await fs.markBrowserConnected(uid, sid, options.instanceId, pendingTask.intent.taskId) - sessionBridge.registerBrowser(uid, sid, ws) - ws.send(JSON.stringify({ type: 'session_ready', sessionId: sid })) - ws.send(JSON.stringify({ type: 'task', intent: pendingTask.intent })) - } - - async function onResult(raw: unknown): Promise { - if (!authed || !uid || !sessionId) return - const r = resultFrameSchema.safeParse(raw) - if (r.success) { - const result: TaskResult = { taskId: r.data.taskId, status: 'complete', data: r.data.data, activeUrl: r.data.activeUrl } - await fs.writeTaskResult(uid, sessionId, r.data.taskId, result) - ws.send(JSON.stringify({ type: 'session_end' })) - return - } - const e = errorFrameSchema.safeParse(raw) - if (e.success) { - const result: TaskResult = { - taskId: e.data.taskId, status: 'failed', data: {}, activeUrl: '', - error: { - code: e.data.code as BridgeErrorCode, - message: e.data.message, - failedAction: e.data.failedAction as SingleAction, - }, - } - await fs.writeTaskResult(uid, sessionId, e.data.taskId, result) - ws.send(JSON.stringify({ type: 'session_end' })) - } - } - - ws.on('message', (data: Buffer) => { - let parsed: unknown - try { parsed = JSON.parse(data.toString()) } catch { return } - const type = (parsed as { type?: string }).type - if (type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return } - if (!authed) { void onAuth(parsed); return } - if (type === 'task_result' || type === 'task_error') { void onResult(parsed); return } - }) - - ws.on('close', () => { - clearTimeout(authTimer) - if (uid && sessionId) sessionBridge.deregister(uid, sessionId) - }) - ws.on('error', () => { clearTimeout(authTimer) }) -} -``` - -- [ ] **Step 3b: Add `getFirstTask` to `firestoreSession.ts`** - -The handler needs to find the session's single pending task without the extension echoing `taskId`. Add to the returned object in `createFirestoreSession` (Task 5) and to `FirestoreLike` a `collection(...).get()` over the tasks subcollection: - -```typescript - async getFirstTask(uid: string, sid: string): Promise { - const snap = await db.collection(`users/${uid}/sessions/${sid}/tasks`).limit(1).get() - if (snap.empty) return null - return snap.docs[0].data() as unknown as TaskDoc - }, -``` - -> **Decision for Phase 1:** keep the auth frame minimal per spec (`idToken`, `sessionId`, `deviceId`) and read the pending task from Firestore via `getFirstTask`. In the `firestoreSession.test.ts` fake (Task 5), make `collection(path).limit(1).get()` return the task doc for the tasks subcollection path (the existing device-filtering `get()` only applies to the devices path — branch on whether `path` ends in `/tasks`), and add a `getFirstTask` test case there before re-running Task 5's suite. - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/handlers/wsBrowserAgentHandler.test.js` -Expected: PASS (4 tests). Add a `getFirstTask` returning the stub task in the test's `firestoreSession` fake. - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/handlers/wsBrowserAgentHandler.ts cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts cloud-agent/src/services/firestoreSession.ts cloud-agent/src/services/firestoreSession.test.ts -git commit -m "feat(cloud-agent): /agent/browser WS handler" -``` - ---- - -### Task 11: Wire `/agent/browser` into `attachWebSocketRoutes` - -**Files:** -- Modify: `cloud-agent/src/index.ts` - -- [ ] **Step 1: Add the route in `attachWebSocketRoutes`** - -Import at top: - -```typescript -import { handleBrowserWsUpgrade } from './handlers/wsBrowserAgentHandler.js' -import { defaultFirestoreSession } from './services/firestoreSession.js' -import { eq } from 'drizzle-orm' -``` - -(`eq` and `users` are already imported.) Inside `attachWebSocketRoutes`, add a `browserWss` and branch: - -```typescript - const browserWss = new WebSocketServer({ noServer: true }) -``` - -and inside `server.on('upgrade', ...)` add a branch alongside `/agent/stream` and `/agent/live`: - -```typescript - } else if (pathname === '/agent/browser') { - browserWss.handleUpgrade(req, socket, head, (ws) => { - handleBrowserWsUpgrade(ws, req, { - firestoreSession: defaultFirestoreSession(), - verifyToken, - resolveUserId: async (firebaseUid: string) => { - const [u] = await db.select({ id: users.id }).from(users).where(eq(users.firebaseUid, firebaseUid)) - return u?.id ?? null - }, - validateDevice: async (uid: string, deviceId: string) => { - const doc = await admin.firestore().doc(`users/${uid}/devices/${deviceId}`).get() - return doc.exists && (doc.data()?.active === true) - }, - instanceId: INSTANCE_ID, - }) - }) -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd cloud-agent && npx tsc --noEmit` -Expected: no errors. - -- [ ] **Step 3: Commit** - -```bash -git add cloud-agent/src/index.ts -git commit -m "feat(cloud-agent): mount /agent/browser WS route" -``` - ---- - -### Task 12: `pauseBilling()` / `resumeBilling()` in the live handler - -**Files:** -- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.ts` -- Test: `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` (append) - -The voice billing timer must pause while a `browser_action` waits for the extension. Expose pause/resume on a controller object the tool can reach. For Phase 1 we make pause/resume process-reachable per session via a registry keyed by `${uid}:${liveSessionId}`; the tool calls them through the same `BrowserActionDeps`. - -- [ ] **Step 1: Write the failing test (append to existing test file)** - -```typescript -import test from 'node:test' -import assert from 'node:assert/strict' - -const { makeBillingController } = await import('./wsLiveAgentHandler.js') - -test('pauseBilling stops the interval from spending; resume restarts', () => { - let spends = 0 - const fakeSetInterval = (fn: () => void) => { ;(fakeSetInterval as unknown as { fn: () => void }).fn = fn; return 1 as unknown as ReturnType } - const ctrl = makeBillingController({ - spend: () => { spends++ }, - setIntervalFn: fakeSetInterval as never, - clearIntervalFn: () => {}, - intervalMs: 1000, - }) - ctrl.start() - ;(fakeSetInterval as unknown as { fn: () => void }).fn() // tick → spend - ctrl.pause() - ;(fakeSetInterval as unknown as { fn: () => void }).fn() // tick while paused → no spend - ctrl.resume() - ;(fakeSetInterval as unknown as { fn: () => void }).fn() // tick → spend - assert.equal(spends, 2) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/handlers/wsLiveAgentHandler.test.js` -Expected: FAIL — `makeBillingController` not exported. - -- [ ] **Step 3: Extract the timer into `makeBillingController` and add pause/resume** - -Add this exported factory to `wsLiveAgentHandler.ts`: - -```typescript -export interface BillingControllerOpts { - spend: () => void - intervalMs: number - setIntervalFn?: typeof setInterval - clearIntervalFn?: typeof clearInterval -} - -export function makeBillingController(opts: BillingControllerOpts) { - const setI = opts.setIntervalFn ?? setInterval - const clearI = opts.clearIntervalFn ?? clearInterval - let timer: ReturnType | null = null - let paused = false - return { - start() { timer = setI(() => { if (!paused) opts.spend() }, opts.intervalMs) }, - pause() { paused = true }, - resume() { paused = false }, - stop() { if (timer !== null) { clearI(timer); timer = null } }, - } -} -export type BillingController = ReturnType -``` - -Then, in `handleLiveWsUpgrade`, replace the inline `billingTimer = setInterval(...)` with a controller built from the same spend body, store the controller in a module-level registry keyed by `${userId}:${sessionId}` so the tool can pause/resume it, and call `controller.stop()` in `clearAndClose()`. Expose: - -```typescript -const billingControllers = new Map() -export function getBillingController(key: string): BillingController | undefined { return billingControllers.get(key) } -``` - -> Keep the existing spend/INSUFFICIENT_CREDITS handling inside the `spend` callback. The live session's `sessionId` for the registry key is the bridge-independent live WS id; generate one at auth time (`const liveSessionKey = `${userId}:${crypto.randomUUID()}`) and pass it into `BrowserActionDeps` via the live tool set (Task 14 wiring). - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/handlers/wsLiveAgentHandler.test.js` -Expected: PASS (existing tests + 1 new). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/handlers/wsLiveAgentHandler.ts cloud-agent/src/handlers/wsLiveAgentHandler.test.ts -git commit -m "feat(cloud-agent): pausable billing controller for voice" -``` - ---- - -### Task 13: `browserAction.ts` — the `browser_action` tool - -**Files:** -- Create: `cloud-agent/src/tools/browserAction.ts` -- Test: `cloud-agent/src/tools/browserAction.test.ts` - -This is the heart. Owns `sessionId`/`taskId`; resolves device (no credit if none/paused); contextual billing; writes task; FCM wake; 12s durable wake timeout w/ refund; result delivery (voice = listener + interim, text = `Promise.race` 30s). - -- [ ] **Step 1: Write the failing test** - -```typescript -// cloud-agent/src/tools/browserAction.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' - -const { browserActionTool } = await import('./browserAction.js') - -function baseDeps(over: Record = {}) { - const calls: Record = { spend: 0, refund: 0, wake: 0, writeTask: 0 } - return { - calls, - deps: { - uid: 'u1', - firestoreSession: { - getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'tok', deviceName: 'Mac' }), - createSession: async () => {}, - writeTask: async () => { calls.writeTask++ }, - writeTaskResult: async () => {}, - getTask: async () => ({ status: 'pending' }), - getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }), - closeSession: async () => {}, - watchTask: (_u: string, _s: string, _t: string, cb: (d: unknown) => void) => { - // simulate a completed task arriving shortly - setTimeout(() => cb({ status: 'complete', result: { data: { price: '$5' }, activeUrl: 'https://x' } }), 5) - return () => {} - }, - }, - fcmDispatcher: { wakeExtension: async () => { calls.wake++ } }, - creditService: { spendCredit: async () => { calls.spend++; return 'tx1' }, refundCredit: async () => { calls.refund++ } }, - instanceId: 'i1', - wakeTimeoutMs: 50, - textTimeoutMs: 200, - ...over, - }, - } -} - -test('no device → tool error, no credit spent', async () => { - const { deps, calls } = baseDeps({ - firestoreSession: { ...baseDeps().deps.firestoreSession, getActiveDevice: async () => null }, - }) - const tool = browserActionTool(deps as never, { trigger: 'text', preBilled: true }) - const out = await (tool as unknown as { execute: (a: unknown) => Promise }).execute({ - actionSummary: 'x', intent: { action: { type: 'read_dom', selector: 'body' } }, - }) - assert.match(out, /not paired|Install/i) - assert.equal(calls.spend, 0) - assert.equal(calls.wake, 0) -}) - -test('text path is preBilled → skips spendCredit', async () => { - const { deps, calls } = baseDeps() - const tool = browserActionTool(deps as never, { trigger: 'text', preBilled: true }) - await (tool as unknown as { execute: (a: unknown) => Promise }).execute({ - actionSummary: 'x', intent: { action: { type: 'extract', selector: '.p', label: 'price' } }, - }) - assert.equal(calls.spend, 0) - assert.equal(calls.wake, 1) - assert.equal(calls.writeTask, 1) -}) - -test('voice path spends a credit', async () => { - const { deps, calls } = baseDeps() - const tool = browserActionTool(deps as never, { trigger: 'voice', preBilled: false }) - await (tool as unknown as { execute: (a: unknown) => Promise }).execute({ - actionSummary: 'x', intent: { action: { type: 'read_dom', selector: 'body' } }, - }) - assert.equal(calls.spend, 1) -}) - -test('text path returns the completed result string', async () => { - const { deps } = baseDeps() - const tool = browserActionTool(deps as never, { trigger: 'text', preBilled: true }) - const out = await (tool as unknown as { execute: (a: unknown) => Promise }).execute({ - actionSummary: 'x', intent: { action: { type: 'extract', selector: '.p', label: 'price' } }, - }) - assert.match(out, /\$5/) -}) - -test('voice wake timeout (no connect) refunds and reports offline', async () => { - const fs = { - ...baseDeps().deps.firestoreSession, - getTask: async () => ({ status: 'pending' }), - getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }), - watchTask: (_u: string, _s: string, _t: string, cb: (d: unknown) => void) => { - setTimeout(() => cb({ status: 'failed', result: null, error: { code: 'EXTENSION_OFFLINE', message: 'offline' } }), 60) - return () => {} - }, - } - const { deps, calls } = baseDeps({ firestoreSession: fs }) - const tool = browserActionTool(deps as never, { trigger: 'voice', preBilled: false }) - const out = await (tool as unknown as { execute: (a: unknown) => Promise }).execute({ - actionSummary: 'x', intent: { action: { type: 'read_dom', selector: 'body' } }, - }) - await new Promise((r) => setTimeout(r, 80)) - assert.equal(calls.refund, 1) - assert.match(out, /sent|offline|browser/i) -}) -``` - -> Note: the voice path returns an *interim* string immediately and resolves the final result through the live session push, which is out-of-band of the tool return. For unit-testing simplicity the tool's `execute` resolves with the interim string on voice and the final string on text. The interim-vs-final distinction is asserted by checking the text path returns data and voice returns the interim sentence. - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/tools/browserAction.test.js` -Expected: FAIL — cannot find `./browserAction.js`. - -- [ ] **Step 3: Write `cloud-agent/src/tools/browserAction.ts`** - -```typescript -import { FunctionTool } from '@google/adk' -import { z } from 'zod' -import type { FirestoreSession } from '../services/firestoreSession.js' -import type { FcmDispatcher } from '../services/fcmDispatcher.js' -import type { CreditService } from '../services/creditService.js' -import type { TaskIntent, TaskDoc } from '../../../shared/dsl-types.js' -import { DESTRUCTIVE_ACTION_PATTERN } from '../../../shared/constants.js' - -export interface BrowserActionDeps { - uid: string - firestoreSession: FirestoreSession - fcmDispatcher: FcmDispatcher - creditService: CreditService - instanceId: string - // Voice-only: pause/resume the wall-clock billing timer while waiting. - pauseBilling?: () => void - resumeBilling?: () => void - // Voice-only: push the final result into the live Gemini session. - pushToLive?: (text: string) => void - wakeTimeoutMs?: number // default 12_000 - textTimeoutMs?: number // default 30_000 -} - -const browserActionSchema = z.object({ - actionSummary: z.string().describe( - 'Human-readable description of what you are about to do (e.g. "Checking the article on your browser").', - ), - intent: z.object({ - action: z.record(z.unknown()).describe('SingleAction or SequenceAction — see Task DSL spec.'), - }), -}) - -function formatResult(task: TaskDoc): string { - if (task.status === 'complete' && task.result) { - const data = task.result.data ?? {} - const body = Object.keys(data).length ? JSON.stringify(data) : '(no extracted data)' - return `Browser task complete on ${task.result.activeUrl}: ${body}` - } - const code = task.error?.code ?? task.result?.error?.code ?? 'EXECUTION_ERROR' - if (code === 'EXTENSION_OFFLINE') return 'Your browser extension appears to be offline.' - return `Browser task failed (${code}): ${task.error?.message ?? task.result?.error?.message ?? 'unknown error'}` -} - -export function browserActionTool( - deps: BrowserActionDeps, - context: { trigger: 'voice' | 'text'; preBilled: boolean }, -): FunctionTool { - return new FunctionTool({ - name: 'browser_action', - description: - 'Perform a web task on the user\'s paired desktop browser (read pages, extract data, navigate). ' + - 'Use only when the user asks you to look at or act on something in their browser.', - parameters: browserActionSchema, - execute: async (args: unknown): Promise => { - const { actionSummary, intent } = args as z.infer - const fs = deps.firestoreSession - const wakeTimeoutMs = deps.wakeTimeoutMs ?? 12_000 - const textTimeoutMs = deps.textTimeoutMs ?? 30_000 - - const sessionId = crypto.randomUUID() - const taskId = crypto.randomUUID() - - // 1. Resolve device BEFORE spending credit. - const device = await fs.getActiveDevice(deps.uid) - if (!device) { - return 'No browser extension is paired. Install the Clanker Desktop Bridge extension, or it may be paused — enable it from the extension.' - } - - // 2. Contextual billing. - deps.pauseBilling?.() - let txId: string | null = null - if (!context.preBilled) { - try { txId = await deps.creditService.spendCredit(deps.uid) } - catch { deps.resumeBilling?.(); return 'You are out of credits for browser actions.' } - } - - // 3. Build intent + persist. - const action = intent.action as TaskIntent['action'] - const requiresAuth = DESTRUCTIVE_ACTION_PATTERN.test(actionSummary) - const taskIntent: TaskIntent = { version: '1', taskId, sessionId, requiresAuth, actionSummary, action } - await fs.createSession(deps.uid, sessionId, { status: 'pending', trigger: context.trigger, voiceInstanceId: deps.instanceId }) - await fs.writeTask(deps.uid, sessionId, taskId, taskIntent) - - // 4. Wake. - await deps.fcmDispatcher.wakeExtension(device.fcmToken, sessionId, taskId) - - // 5. Durable wake timeout (queries Firestore, never sessionBridge). - const wakeTimer = setTimeout(() => { void enforceWakeTimeout() }, wakeTimeoutMs) - let settled = false - async function enforceWakeTimeout(): Promise { - if (settled) return - const task = await fs.getTask(deps.uid, sessionId, taskId) - const session = await fs.getSession(deps.uid, sessionId) - const connected = task.status === 'executing' || session.browserInstanceId != null || session.browserConnectedAt != null - if (!connected && task.status === 'pending') { - await fs.writeTaskResult(deps.uid, sessionId, taskId, { - taskId, status: 'failed', data: {}, activeUrl: '', - error: { code: 'EXTENSION_OFFLINE', message: 'Browser extension did not connect', failedAction: action as never }, - }) - if (txId) { try { await deps.creditService.refundCredit(deps.uid, txId) } catch { /* logged */ } } - await fs.closeSession(deps.uid, sessionId, 'aborted') - } - } - - // 6. Result delivery. - const watch = (resolve: (task: TaskDoc) => void) => { - const unsub = fs.watchTask(deps.uid, sessionId, taskId, (task) => { - if (task.status === 'complete' || task.status === 'failed' || task.status === 'aborted') { - settled = true; clearTimeout(wakeTimer); unsub(); resolve(task) - } - }) - return unsub - } - - if (context.trigger === 'text') { - const result = await Promise.race([ - new Promise((resolve) => watch(resolve)), - new Promise((_, reject) => - setTimeout(() => reject(new Error('EXECUTION_TIMEOUT')), textTimeoutMs)), - ]).catch(() => ({ - status: 'failed', error: { code: 'EXECUTION_TIMEOUT', message: 'Browser task exceeded 30s' }, - } as unknown as TaskDoc)) - return formatResult(result) - } - - // Voice: resolve final result out-of-band into the live session; return interim now. - void new Promise((resolve) => watch(resolve)).then((task) => { - deps.resumeBilling?.() - deps.pushToLive?.(formatResult(task)) - }) - return 'Sent the task to your browser. I\'ll read the result aloud when it arrives.' - }, - }) -} -``` - -> The `failedAction: action as never` casts are because `action` may be a `SequenceAction`; for `failedAction` the extension always reports the specific `SingleAction` — Cloud-side timeout has no single action, so an empty/whole-action value is acceptable here. Tighten if `tsc` complains by narrowing to `SingleAction | undefined`. - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/tools/browserAction.test.js` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/tools/browserAction.ts cloud-agent/src/tools/browserAction.test.ts -git commit -m "feat(cloud-agent): browser_action tool with contextual billing + durable wake timeout" -``` - ---- - -### Task 14: Inject `browser_action` into both agent entry points - -**Files:** -- Modify: `cloud-agent/src/services/agentCore.ts` (text `/agent/run`) -- Modify: `cloud-agent/src/services/liveToolAdapter.ts` (voice `/agent/live`) -- Test: extend `agentCore.test.ts` / `liveToolAdapter.test.ts` if present, else add `cloud-agent/src/tools/browserActionWiring.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// cloud-agent/src/tools/browserActionWiring.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' - -const { buildLiveTools } = await import('../services/liveToolAdapter.js') - -test('buildLiveTools registers browser_action when bridge deps provided', () => { - const fakeDb = {} as never - const embed = async () => [0] - const { declarations } = buildLiveTools(fakeDb, 'u1', 'c1', embed, 'UTC', { - firestoreSession: {} as never, fcmDispatcher: {} as never, creditService: {} as never, - instanceId: 'i1', uid: 'u1', - } as never) - assert.ok(declarations.some((d) => d.name === 'browser_action')) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd cloud-agent && NODE_ENV=test npm run build && node --test dist/cloud-agent/src/tools/browserActionWiring.test.js` -Expected: FAIL — `buildLiveTools` does not accept bridge deps / no `browser_action`. - -- [ ] **Step 3a: Extend `buildLiveTools` in `liveToolAdapter.ts`** - -Add an optional 6th param and push the tool: - -```typescript -import { browserActionTool, type BrowserActionDeps } from '../tools/browserAction.js' - -export function buildLiveTools( - db: DrizzleClient, - userId: string, - characterId: string, - embed: EmbedFn, - timezone: string, - bridge?: Omit & { - pushToLive?: (t: string) => void; pauseBilling?: () => void; resumeBilling?: () => void - }, -): LiveToolSet { - const adkTools: FunctionTool[] = [ - // ...existing tools unchanged... - ] - if (bridge) { - adkTools.push(browserActionTool(bridge, { trigger: 'voice', preBilled: false })) - } - // ...existing declarations/executors construction unchanged... -} -``` - -(Keep the existing tool list and the `declarations`/`executors` mapping exactly as-is; just append to `adkTools` before they're computed.) - -- [ ] **Step 3b: Extend `buildAgent` in `agentCore.ts`** - -```typescript -import { browserActionTool, type BrowserActionDeps } from '../tools/browserAction.js' - -export function buildAgent( - db: DrizzleClient, - userId: string, - characterId: string, - systemInstruction: string, - timezone: string, - embed: (text: string) => Promise, - bridge?: BrowserActionDeps, -): LlmAgent { - const tools = [ - // ...existing tools unchanged... - GOOGLE_SEARCH, - ] - if (bridge) tools.push(browserActionTool(bridge, { trigger: 'text', preBilled: true })) - return new LlmAgent({ name: 'clanker-cloud-agent', model: 'gemini-3.5-flash', instruction: systemInstruction, tools }) -} -``` - -- [ ] **Step 3c: Pass bridge deps from the call sites** - -In `index.ts` `runAgentReal`, build the bridge deps from defaults and pass to `buildAgent`: - -```typescript -import { defaultFcmDispatcher } from './services/fcmDispatcher.js' -import { defaultFirestoreSession } from './services/firestoreSession.js' -// inside runAgentReal, after computing userId: -const bridge = { - uid: userId, - firestoreSession: defaultFirestoreSession(), - fcmDispatcher: defaultFcmDispatcher(), - creditService: createCreditService(db), - instanceId: INSTANCE_ID, -} -const agent = buildAgent(db, userId, characterId, systemInstruction, timezone, embed, bridge) -``` - -In `wsLiveAgentHandler.ts` `handleAuthMessage`, pass bridge deps + pause/resume/push into `buildLiveTools`: - -```typescript -const controller = makeBillingController({ spend: spendOnce, intervalMs: billingIntervalMs, clearIntervalFn: clearIntervalFn as never }) -const { declarations, executors } = buildLiveTools(db, userId!, characterId, embedText, timezone, { - uid: userId!, - firestoreSession: defaultFirestoreSession(), - fcmDispatcher: defaultFcmDispatcher(), - creditService: cs, - instanceId: INSTANCE_ID, - pauseBilling: () => controller.pause(), - resumeBilling: () => controller.resume(), - pushToLive: (text: string) => { - try { geminiSession?.sendToolResponse({ functionResponses: [{ id: crypto.randomUUID(), name: 'browser_action', response: { output: text } }] }) } catch { /* ignore */ } - }, -}) -controller.start() -``` - -(Replace the old inline `billingTimer = setInterval(...)` block with the controller; move the existing spend body into a `spendOnce()` function. Import `INSTANCE_ID` from `../services/instanceId.js` — NOT from `../index.js`, which would create an import cycle — plus `defaultFcmDispatcher`/`defaultFirestoreSession`.) - -- [ ] **Step 4: Run the wiring test + full suite to confirm pass** - -Run: `cd cloud-agent && NODE_ENV=test npm test` -Expected: all tests PASS (existing + new browser bridge tests). - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src -git commit -m "feat(cloud-agent): wire browser_action into voice + text agents" -``` - ---- - -### Task 15: Cloud Agent gate — full typecheck + suite green - -**Files:** none (verification gate). - -- [ ] **Step 1: Typecheck** - -Run: `cd cloud-agent && npx tsc --noEmit` -Expected: no errors. - -- [ ] **Step 2: Full test suite** - -Run: `cd cloud-agent && NODE_ENV=test npm test` -Expected: all green, including `firestoreSession`, `fcmDispatcher`, `sessionBridge`, `wsBrowserAgentHandler`, `registerDevice`, `browserAction`, wiring, and live-handler billing controller. - -- [ ] **Step 3: Commit (if any fixups were needed)** - -```bash -git add -A && git commit -m "test(cloud-agent): green bridge suite" || echo "nothing to commit" -``` - ---- - -## PART C — MV3 Extension (greenfield) - -### Task 16: Extension scaffold — package, build, manifest, test harness - -**Files:** -- Create: `extension/package.json`, `extension/tsconfig.json`, `extension/esbuild.mjs`, `extension/manifest.json` -- Create: `extension/test/chrome-stub.ts` -- Create: `extension/icons/icon-16.png`, `icon-48.png`, `icon-128.png` (placeholder 1×1 PNGs OK for dev; replace before CWS) -- Create: `extension/src/env.ts` (Firebase config + sender id + cloud base URL) - -- [ ] **Step 1: Write `extension/package.json`** - -```json -{ - "name": "clanker-desktop-bridge", - "private": true, - "type": "module", - "scripts": { - "build": "node esbuild.mjs", - "typecheck": "tsc --noEmit", - "test": "node --import tsx/esm --test \"src/**/*.test.ts\"" - }, - "dependencies": { - "firebase": "^10.12.0" - }, - "devDependencies": { - "@types/chrome": "^0.0.268", - "esbuild": "^0.21.0", - "jsdom": "^24.0.0", - "tsx": "^4.10.0", - "typescript": "^5.4.0" - } -} -``` - -- [ ] **Step 2: Write `extension/tsconfig.json`** - -```json -{ - "compilerOptions": { - "target": "es2022", - "module": "esnext", - "moduleResolution": "bundler", - "lib": ["es2022", "dom", "dom.iterable"], - "types": ["chrome", "node"], - "strict": true, - "skipLibCheck": true, - "noEmit": true, - "esModuleInterop": true, - "resolveJsonModule": true - }, - "include": ["src", "../shared"] -} -``` - -- [ ] **Step 3: Write `extension/esbuild.mjs`** - -```javascript -import { build } from 'esbuild' -import { cpSync, mkdirSync } from 'node:fs' - -const entries = { - 'background/service-worker': 'src/background/service-worker.ts', - 'offscreen/auth': 'src/offscreen/auth.ts', - 'content/executor': 'src/content/executor.ts', - 'ui/side-panel/panel': 'src/ui/side-panel/panel.ts', - 'ui/popup/popup': 'src/ui/popup/popup.ts', -} - -mkdirSync('dist', { recursive: true }) - -await build({ - entryPoints: entries, - outdir: 'dist', - bundle: true, - format: 'esm', - target: 'chrome120', - sourcemap: true, -}) - -// Static assets -for (const f of ['manifest.json']) cpSync(f, `dist/${f}`) -cpSync('icons', 'dist/icons', { recursive: true }) -cpSync('src/offscreen/auth.html', 'dist/offscreen/auth.html') -cpSync('src/ui/side-panel/index.html', 'dist/ui/side-panel/index.html') -cpSync('src/ui/popup/index.html', 'dist/ui/popup/index.html') -console.log('extension built → dist/') -``` - -- [ ] **Step 4: Write `extension/manifest.json`** (output paths reference the bundled `dist/` layout) - -```json -{ - "manifest_version": 3, - "name": "Clanker Desktop Bridge", - "version": "0.1.0", - "description": "Lets your Clanker agent perform web tasks you request on this browser.", - "background": { "service_worker": "background/service-worker.js", "type": "module" }, - "content_scripts": [], - "permissions": ["scripting", "storage", "sidePanel", "notifications", "gcm", "offscreen"], - "optional_host_permissions": [""], - "side_panel": { "default_path": "ui/side-panel/index.html" }, - "action": { - "default_popup": "ui/popup/index.html", - "default_icon": { "16": "icons/icon-16.png", "48": "icons/icon-48.png" } - } -} -``` - -> The `key` field (stable extension ID) is added before CWS submission in Phase 4; the private `.pem` is never committed. - -- [ ] **Step 5: Write `extension/src/env.ts`** - -```typescript -// FIREBASE_API_KEY is a public identifier, safe to embed. Replace placeholders -// with the project's real values before loading the extension. -export const FIREBASE_CONFIG = { - apiKey: 'REPLACE_FIREBASE_API_KEY', - authDomain: 'REPLACE.firebaseapp.com', - projectId: 'REPLACE', - appId: 'REPLACE', -} -export const FIREBASE_SENDER_ID = 'REPLACE_FCM_SENDER_ID' -export const CLOUD_BASE_URL = 'https://REPLACE-cloud-agent-url' -export const CLOUD_WS_URL = 'wss://REPLACE-cloud-agent-url/agent/browser' -``` - -- [ ] **Step 6: Write `extension/test/chrome-stub.ts`** (installed by tests that need `chrome`) - -```typescript -type Listener = (...args: unknown[]) => void -export function installChromeStub(over: Record = {}): void { - const store: Record = {} - ;(globalThis as { chrome?: unknown }).chrome = { - runtime: { sendMessage: async () => undefined, onMessage: { addListener: (_l: Listener) => {} }, getURL: (p: string) => p }, - storage: { local: { - get: async (k: string) => ({ [k]: store[k] }), - set: async (o: Record) => { Object.assign(store, o) }, - } }, - gcm: { register: (_ids: string[], cb: (t: string) => void) => cb('gcm-token'), onMessage: { addListener: (_l: Listener) => {} } }, - offscreen: { hasDocument: async () => false, createDocument: async () => {}, closeDocument: async () => {} }, - scripting: { executeScript: async () => [{ result: undefined }] }, - permissions: { contains: async () => true, request: async () => true }, - notifications: { create: () => {} }, - tabs: { create: async () => ({ id: 1 }), query: async () => [{ id: 1, url: 'https://x' }], update: async () => ({}) }, - sidePanel: { open: async () => {} }, - ...over, - } -} -``` - -- [ ] **Step 7: Verify scaffold installs and typechecks (no source yet → expect missing-entry errors only)** - -Run: `cd extension && npm install && npx tsc --noEmit` -Expected: tsc reports missing source files referenced by esbuild only when built; `tsc --noEmit` with empty `src` (besides env.ts + test stub) passes. (If it errors on no inputs, add a temporary `src/index.ts` placeholder and remove it in Task 17.) - -- [ ] **Step 8: Commit** - -```bash -git add extension/package.json extension/tsconfig.json extension/esbuild.mjs extension/manifest.json extension/src/env.ts extension/test/chrome-stub.ts extension/icons -git commit -m "chore(extension): scaffold MV3 package, build, manifest, test harness" -``` - ---- - -### Task 17: Extension shared re-exports (single source of truth) - -**Files:** -- Create: `extension/src/shared/constants.ts` -- Create: `extension/src/shared/dsl-types.ts` - -These re-export the repo-root canonical modules so the destructive pattern and wire types never drift. - -- [ ] **Step 1: Write `extension/src/shared/constants.ts`** - -```typescript -export { DESTRUCTIVE_ACTION_PATTERN, classifyActionLabel } from '../../../shared/constants.js' -``` - -- [ ] **Step 2: Write `extension/src/shared/dsl-types.ts`** - -```typescript -export type { - SingleAction, SequenceAction, TaskIntent, TaskResult, BridgeErrorCode, -} from '../../../shared/dsl-types.js' -``` - -- [ ] **Step 3: Verify esbuild can resolve the cross-package path** - -Run: `cd extension && node -e "import('esbuild').then(e=>e.build({entryPoints:['src/shared/constants.ts'],bundle:true,write:false,format:'esm'})).then(()=>console.log('ok'))"` -Expected: `ok` (esbuild bundles `../../../shared/constants.ts`). - -- [ ] **Step 4: Commit** - -```bash -git add extension/src/shared -git commit -m "chore(extension): re-export shared DSL constants/types" -``` - ---- - -### Task 18: `content/dom-extractor.ts` — read primitives (jsdom-tested) - -**Files:** -- Create: `extension/src/content/dom-extractor.ts` -- Test: `extension/src/content/dom-extractor.test.ts` - -Pure DOM functions operating on a passed `Document` so they're jsdom-testable. - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/content/dom-extractor.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { JSDOM } from 'jsdom' -import { extract, readDom, summarizeVisibleText } from './dom-extractor.js' - -function doc(html: string) { return new JSDOM(html).window.document } - -test('extract returns matched text keyed by label', () => { - const d = doc('
$42.99
') - assert.deepEqual(extract(d, '.price', 'price'), { price: '$42.99' }) -}) - -test('extract throws SELECTOR_NOT_FOUND when missing', () => { - const d = doc('
') - assert.throws(() => extract(d, '.nope', 'x'), /SELECTOR_NOT_FOUND/) -}) - -test('readDom returns innerHTML of the selector', () => { - const d = doc('
hi
') - assert.match(readDom(d, '#s'), /hi<\/b>/) -}) - -test('summarizeVisibleText drops nav when filter=no_nav', () => { - const d = doc('
Body text here.
') - const out = summarizeVisibleText(d, 'no_nav') - assert.match(out, /Body text here/) - assert.doesNotMatch(out, /MENU/) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/content/dom-extractor.test.ts` -Expected: FAIL — cannot find `./dom-extractor.js`. - -- [ ] **Step 3: Write `extension/src/content/dom-extractor.ts`** - -```typescript -export function extract(doc: Document, selector: string, label = 'value'): Record { - const el = doc.querySelector(selector) - if (!el) throw new Error('SELECTOR_NOT_FOUND') - return { [label]: (el.textContent ?? '').trim() } -} - -export function readDom(doc: Document, selector: string): string { - const el = doc.querySelector(selector) - if (!el) throw new Error('SELECTOR_NOT_FOUND') - return el.innerHTML -} - -export function summarizeVisibleText(doc: Document, filter: 'no_nav' | 'no_ads' | 'all' = 'all'): string { - const drop = new Set() - if (filter === 'no_nav') ['nav', 'header', 'footer', 'aside'].forEach((t) => drop.add(t)) - if (filter === 'no_ads') ['aside', '[role=banner]'].forEach((t) => drop.add(t)) - const clone = doc.body.cloneNode(true) as HTMLElement - for (const sel of drop) clone.querySelectorAll(sel).forEach((n) => n.remove()) - return (clone.textContent ?? '').replace(/\s+/g, ' ').trim() -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/content/dom-extractor.test.ts` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/content/dom-extractor.ts extension/src/content/dom-extractor.test.ts -git commit -m "feat(extension): DOM read primitives" -``` - ---- - -### Task 19: `content/safety-classifier.ts` — Layer 2 validator - -**Files:** -- Create: `extension/src/content/safety-classifier.ts` -- Test: `extension/src/content/safety-classifier.test.ts` - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/content/safety-classifier.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { JSDOM } from 'jsdom' -import { classifyElement } from './safety-classifier.js' - -function el(html: string) { return new JSDOM(`${html}`).window.document.body.firstElementChild! } - -test('destructive button text → requires_auth', () => { - assert.equal(classifyElement(el('')), 'requires_auth') -}) - -test('benign link → safe', () => { - assert.equal(classifyElement(el('Read more')), 'safe') -}) - -test('submit input inside a form → requires_auth', () => { - const form = new JSDOM('
').window.document.querySelector('input')! - assert.equal(classifyElement(form), 'requires_auth') -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/content/safety-classifier.test.ts` -Expected: FAIL — cannot find `./safety-classifier.js`. - -- [ ] **Step 3: Write `extension/src/content/safety-classifier.ts`** - -```typescript -import { DESTRUCTIVE_ACTION_PATTERN } from '../shared/constants.js' - -export function classifyElement(el: Element): 'safe' | 'requires_auth' { - const text = (el.textContent ?? '').toLowerCase() - if (DESTRUCTIVE_ACTION_PATTERN.test(text)) return 'requires_auth' - if (el.closest('form') && el.matches('[type=submit]')) return 'requires_auth' - return 'safe' -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/content/safety-classifier.test.ts` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/content/safety-classifier.ts extension/src/content/safety-classifier.test.ts -git commit -m "feat(extension): Layer 2 destructive-element classifier" -``` - ---- - -### Task 20: `content/executor.ts` — action runner (injected per-task) - -**Files:** -- Create: `extension/src/content/executor.ts` -- Test: `extension/src/content/executor.test.ts` - -`runAction` takes a `Document`/`Window`-like context so it's jsdom-testable; the real injection wrapper reads `document`/`window`. Phase 1 supports read + navigation primitives; `fill_field`/`click` return an `EXECUTION_ERROR` (not implemented in Phase 1) — they never reach here because the cloud agent only emits read/nav actions, but fail-closed defensively. - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/content/executor.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { JSDOM } from 'jsdom' -import { runAction } from './executor.js' - -function ctx(html: string) { - const dom = new JSDOM(html, { url: 'https://example.com/' }) - let scrolled = 0 - return { - doc: dom.window.document, - win: { scrollBy: (_x: number, y: number) => { scrolled += y }, location: { href: dom.window.location.href }, get scrolled() { return scrolled } }, - } -} - -test('extract action returns data record', async () => { - const c = ctx('$9') - const r = await runAction({ type: 'extract', selector: '.p', label: 'price' }, c.doc, c.win as never) - assert.deepEqual(r.data, { price: '$9' }) -}) - -test('read_dom returns html under read_dom key', async () => { - const c = ctx('
z
') - const r = await runAction({ type: 'read_dom', selector: '#x' }, c.doc, c.win as never) - assert.match(r.data.read_dom, /z<\/i>/) -}) - -test('scroll down moves the viewport', async () => { - const c = ctx('') - await runAction({ type: 'scroll', direction: 'down', pixels: 200 }, c.doc, c.win as never) - assert.equal(c.win.scrolled, 200) -}) - -test('stateful action fails closed in Phase 1', async () => { - const c = ctx('') - await assert.rejects( - () => runAction({ type: 'click', selector: 'button', tier: 'stateful' }, c.doc, c.win as never), - /EXECUTION_ERROR/, - ) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/content/executor.test.ts` -Expected: FAIL — cannot find `./executor.js`. - -- [ ] **Step 3: Write `extension/src/content/executor.ts`** - -```typescript -import type { SingleAction } from '../shared/dsl-types.js' -import { extract, readDom, summarizeVisibleText } from './dom-extractor.js' - -export interface ActionOutcome { data: Record; activeUrl: string } - -interface WinLike { scrollBy(x: number, y: number): void; location: { href: string } } - -export async function runAction(action: SingleAction, doc: Document, win: WinLike): Promise { - const activeUrl = win.location.href - switch (action.type) { - case 'extract': - return { data: extract(doc, action.selector, action.label ?? 'value'), activeUrl } - case 'read_dom': - return { data: { read_dom: readDom(doc, action.selector) }, activeUrl } - case 'summarize_visible_text': - return { data: { summary: summarizeVisibleText(doc, action.filter ?? 'all') }, activeUrl } - case 'scroll': { - const delta = (action.pixels ?? 600) * (action.direction === 'up' ? -1 : 1) - win.scrollBy(0, delta) - return { data: {}, activeUrl } - } - // open_tab / focus_tab are handled in the service worker (tabs API), not the page. - case 'open_tab': - case 'focus_tab': - throw new Error('EXECUTION_ERROR: tab actions are handled by the service worker') - case 'fill_field': - case 'click': - throw new Error('EXECUTION_ERROR: stateful actions are not enabled in Phase 1') - default: - throw new Error('EXECUTION_ERROR: unknown action') - } -} - -// Real entry point used by chrome.scripting.executeScript injection. -export async function runActionInPage(action: SingleAction): Promise { - return runAction(action, document, window as unknown as WinLike) -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/content/executor.test.ts` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/content/executor.ts extension/src/content/executor.test.ts -git commit -m "feat(extension): per-task action executor" -``` - ---- - -### Task 21: `background/task-dispatcher.ts` — route intent → tabs/content - -**Files:** -- Create: `extension/src/background/task-dispatcher.ts` -- Test: `extension/src/background/task-dispatcher.test.ts` - -Parses a `TaskIntent`, runs sequence steps in order, routes tab actions to `chrome.tabs`, and page actions to `chrome.scripting.executeScript`. Aggregates `data`. Phase 1: no halt (no stateful actions reach it). - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/background/task-dispatcher.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { dispatchTask } from './task-dispatcher.js' - -function injector(results: Record; activeUrl: string }>) { - const seen: string[] = [] - return { - seen, - runInActiveTab: async (action: { type: string }) => { seen.push(action.type); return results[action.type] }, - openTab: async (_url: string) => { seen.push('open_tab') }, - focusTab: async (_host: string) => { seen.push('focus_tab') }, - } -} - -test('single extract action returns aggregated data', async () => { - const inj = injector({ extract: { data: { price: '$1' }, activeUrl: 'https://x' } }) - const res = await dispatchTask( - { version: '1', taskId: 't', sessionId: 's', requiresAuth: false, actionSummary: 'x', action: { type: 'extract', selector: '.p', label: 'price' } }, - inj as never, - ) - assert.equal(res.status, 'complete') - assert.deepEqual(res.data, { price: '$1' }) -}) - -test('sequence runs steps in order and merges data', async () => { - const inj = injector({ - extract: { data: { total: '$9' }, activeUrl: 'https://x' }, - summarize_visible_text: { data: { summary: 'hi' }, activeUrl: 'https://x' }, - }) - const res = await dispatchTask( - { version: '1', taskId: 't', sessionId: 's', requiresAuth: false, actionSummary: 'x', - action: { type: 'sequence', steps: [ - { type: 'open_tab', url: 'https://x' }, - { type: 'extract', selector: '.t', label: 'total' }, - { type: 'summarize_visible_text', filter: 'no_nav' }, - ] } }, - inj as never, - ) - assert.deepEqual(inj.seen, ['open_tab', 'extract', 'summarize_visible_text']) - assert.deepEqual(res.data, { total: '$9', summary: 'hi' }) -}) - -test('selector failure → failed result with code', async () => { - const inj = { runInActiveTab: async () => { throw new Error('SELECTOR_NOT_FOUND') }, openTab: async () => {}, focusTab: async () => {} } - const res = await dispatchTask( - { version: '1', taskId: 't', sessionId: 's', requiresAuth: false, actionSummary: 'x', action: { type: 'extract', selector: '.x' } }, - inj as never, - ) - assert.equal(res.status, 'failed') - assert.equal(res.error?.code, 'SELECTOR_NOT_FOUND') -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/background/task-dispatcher.test.ts` -Expected: FAIL — cannot find `./task-dispatcher.js`. - -- [ ] **Step 3: Write `extension/src/background/task-dispatcher.ts`** - -```typescript -import type { TaskIntent, SingleAction, TaskResult, BridgeErrorCode } from '../shared/dsl-types.js' - -export interface Injector { - runInActiveTab(action: SingleAction): Promise<{ data: Record; activeUrl: string }> - openTab(url: string): Promise - focusTab(host: string): Promise -} - -function knownCode(msg: string): BridgeErrorCode { - for (const c of ['SELECTOR_NOT_FOUND', 'HOST_NOT_ALLOWED', 'HOST_PERMISSION_REQUIRED', 'EXECUTION_TIMEOUT'] as const) { - if (msg.includes(c)) return c - } - return 'EXECUTION_ERROR' -} - -export async function dispatchTask(intent: TaskIntent, inj: Injector): Promise { - const steps: SingleAction[] = intent.action.type === 'sequence' ? intent.action.steps : [intent.action] - const data: Record = {} - let activeUrl = '' - try { - for (const step of steps) { - if (step.type === 'open_tab') { await inj.openTab(step.url); activeUrl = step.url; continue } - if (step.type === 'focus_tab') { await inj.focusTab(step.host); continue } - const out = await inj.runInActiveTab(step) - Object.assign(data, out.data) - activeUrl = out.activeUrl || activeUrl - } - return { taskId: intent.taskId, status: 'complete', data, activeUrl } - } catch (err) { - const msg = err instanceof Error ? err.message : 'execution failed' - return { - taskId: intent.taskId, status: 'failed', data, activeUrl, - error: { code: knownCode(msg), message: msg, failedAction: steps[0] }, - } - } -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/background/task-dispatcher.test.ts` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/background/task-dispatcher.ts extension/src/background/task-dispatcher.test.ts -git commit -m "feat(extension): task dispatcher with sequence + error mapping" -``` - ---- - -### Task 22: `background/ws-client.ts` — WS wrapper + heartbeat - -**Files:** -- Create: `extension/src/background/ws-client.ts` -- Test: `extension/src/background/ws-client.test.ts` - -WS wrapper that sends the auth frame on open, exposes `onTask`/`onSessionEnd` callbacks, and runs an internal `setInterval` ping loop (NOT `chrome.alarms`). Tests inject a fake `WebSocket` constructor + fake timers. - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/background/ws-client.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { EventEmitter } from 'node:events' -import { createWsClient } from './ws-client.js' - -class FakeSocket extends EventEmitter { - static OPEN = 1 - readyState = 1 - sent: string[] = [] - onopen?: () => void - onmessage?: (e: { data: string }) => void - onclose?: () => void - send(s: string) { this.sent.push(s) } - close() { this.readyState = 3; this.onclose?.() } - fireOpen() { this.onopen?.() } - fireMessage(o: unknown) { this.onmessage?.({ data: JSON.stringify(o) }) } -} - -test('sends auth frame on open', () => { - let sock!: FakeSocket - const client = createWsClient({ - url: 'wss://x', idToken: 'tok', sessionId: 's1', deviceId: 'd1', - WebSocketImpl: (() => { sock = new FakeSocket(); return sock }) as never, - onTask: () => {}, onSessionEnd: () => {}, - }) - client.connect() - sock.fireOpen() - assert.equal(JSON.parse(sock.sent[0]).type, 'auth') - assert.equal(JSON.parse(sock.sent[0]).sessionId, 's1') -}) - -test('routes task and session_end frames', () => { - let sock!: FakeSocket - const tasks: unknown[] = []; let ended = false - const client = createWsClient({ - url: 'wss://x', idToken: 'tok', sessionId: 's1', deviceId: 'd1', - WebSocketImpl: (() => { sock = new FakeSocket(); return sock }) as never, - onTask: (t) => tasks.push(t), onSessionEnd: () => { ended = true }, - }) - client.connect(); sock.fireOpen() - sock.fireMessage({ type: 'session_ready', sessionId: 's1' }) - sock.fireMessage({ type: 'task', intent: { taskId: 't1' } }) - sock.fireMessage({ type: 'session_end' }) - assert.equal(tasks.length, 1) - assert.equal(ended, true) -}) - -test('sendResult emits task_result frame', () => { - let sock!: FakeSocket - const client = createWsClient({ - url: 'wss://x', idToken: 'tok', sessionId: 's1', deviceId: 'd1', - WebSocketImpl: (() => { sock = new FakeSocket(); return sock }) as never, - onTask: () => {}, onSessionEnd: () => {}, - }) - client.connect(); sock.fireOpen() - client.sendResult({ taskId: 't1', status: 'complete', data: { a: 'b' }, activeUrl: 'https://x' }) - const frame = JSON.parse(sock.sent.find((s) => JSON.parse(s).type === 'task_result')!) - assert.deepEqual(frame.data, { a: 'b' }) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/background/ws-client.test.ts` -Expected: FAIL — cannot find `./ws-client.js`. - -- [ ] **Step 3: Write `extension/src/background/ws-client.ts`** - -```typescript -import type { TaskIntent, TaskResult } from '../shared/dsl-types.js' - -interface SocketLike { - readyState: number - onopen: (() => void) | null - onmessage: ((e: { data: string }) => void) | null - onclose: (() => void) | null - send(data: string): void - close(): void -} - -export interface WsClientOpts { - url: string - idToken: string - sessionId: string - deviceId: string - onTask: (intent: TaskIntent) => void - onSessionEnd: () => void - WebSocketImpl?: new (url: string) => SocketLike - pingIntervalMs?: number -} - -export function createWsClient(opts: WsClientOpts) { - const Impl = opts.WebSocketImpl ?? (WebSocket as unknown as new (url: string) => SocketLike) - const pingMs = opts.pingIntervalMs ?? 20_000 - let sock: SocketLike | null = null - let pingTimer: ReturnType | null = null - - function stopPing() { if (pingTimer) { clearInterval(pingTimer); pingTimer = null } } - - return { - connect() { - sock = new Impl(opts.url) - sock.onopen = () => { - sock!.send(JSON.stringify({ type: 'auth', idToken: opts.idToken, sessionId: opts.sessionId, deviceId: opts.deviceId })) - pingTimer = setInterval(() => { try { sock?.send(JSON.stringify({ type: 'ping' })) } catch { /* ignore */ } }, pingMs) - } - sock.onmessage = (e) => { - let msg: { type?: string; intent?: TaskIntent } - try { msg = JSON.parse(e.data) } catch { return } - if (msg.type === 'task' && msg.intent) opts.onTask(msg.intent) - else if (msg.type === 'session_end') { stopPing(); opts.onSessionEnd() } - } - sock.onclose = () => { stopPing() } - }, - sendResult(result: TaskResult) { - if (result.status === 'complete') { - sock?.send(JSON.stringify({ type: 'task_result', taskId: result.taskId, data: result.data, activeUrl: result.activeUrl })) - } else { - sock?.send(JSON.stringify({ type: 'task_error', taskId: result.taskId, code: result.error?.code, message: result.error?.message, failedAction: result.error?.failedAction })) - } - }, - close() { stopPing(); try { sock?.close() } catch { /* ignore */ } }, - } -} -``` - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/background/ws-client.test.ts` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/background/ws-client.ts extension/src/background/ws-client.test.ts -git commit -m "feat(extension): WS client with auth frame + heartbeat" -``` - ---- - -### Task 23: `background/auth-bridge.ts` + offscreen auth document - -**Files:** -- Create: `extension/src/background/auth-bridge.ts` -- Create: `extension/src/offscreen/auth.html` -- Create: `extension/src/offscreen/auth.ts` -- Test: `extension/src/background/auth-bridge.test.ts` - -`auth-bridge` is chrome-glue; unit-test the message round-trip with the chrome stub. The offscreen doc hosts the Firebase Web SDK — verified manually (Task 27). - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/background/auth-bridge.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { installChromeStub } from '../../test/chrome-stub.js' - -test('requestIdToken messages the offscreen doc and returns the token', async () => { - installChromeStub({ - offscreen: { hasDocument: async () => true, createDocument: async () => {}, closeDocument: async () => {} }, - runtime: { sendMessage: async (msg: { type: string }) => (msg.type === 'GET_ID_TOKEN' ? { idToken: 'id-123' } : undefined) }, - }) - const { requestIdToken } = await import('./auth-bridge.js') - assert.equal(await requestIdToken(), 'id-123') -}) - -test('ensureOffscreen creates a document only when absent', async () => { - let created = 0 - installChromeStub({ - offscreen: { hasDocument: async () => false, createDocument: async () => { created++ }, closeDocument: async () => {} }, - }) - const { ensureOffscreen } = await import('./auth-bridge.js') - await ensureOffscreen() - assert.equal(created, 1) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/background/auth-bridge.test.ts` -Expected: FAIL — cannot find `./auth-bridge.js`. - -- [ ] **Step 3: Write `extension/src/background/auth-bridge.ts`** - -```typescript -const OFFSCREEN_PATH = 'offscreen/auth.html' - -export async function ensureOffscreen(): Promise { - if (await chrome.offscreen.hasDocument()) return - await chrome.offscreen.createDocument({ - url: OFFSCREEN_PATH, - reasons: ['DOM_PARSER' as chrome.offscreen.Reason], - justification: 'Required to host Firebase Web Auth SDK which relies on DOM storage APIs', - }) -} - -export async function requestIdToken(): Promise { - await ensureOffscreen() - const res = (await chrome.runtime.sendMessage({ target: 'offscreen-auth', type: 'GET_ID_TOKEN' })) as { idToken?: string; error?: string } | undefined - if (!res?.idToken) throw new Error(res?.error ?? 'Not signed in. Open the side panel to sign in.') - return res.idToken -} - -export async function closeOffscreen(): Promise { - if (await chrome.offscreen.hasDocument()) await chrome.offscreen.closeDocument() -} -``` - -- [ ] **Step 4: Write `extension/src/offscreen/auth.html`** - -```html - - - -``` - -- [ ] **Step 5: Write `extension/src/offscreen/auth.ts`** - -```typescript -import { initializeApp } from 'firebase/app' -import { getAuth, setPersistence, browserLocalPersistence } from 'firebase/auth' -import { FIREBASE_CONFIG } from '../env.js' - -const app = initializeApp(FIREBASE_CONFIG) -const auth = getAuth(app) -void setPersistence(auth, browserLocalPersistence) - -chrome.runtime.onMessage.addListener((msg: { target?: string; type?: string }, _sender, sendResponse) => { - if (msg.target !== 'offscreen-auth') return - if (msg.type === 'GET_ID_TOKEN') { - const user = auth.currentUser - if (!user) { sendResponse({ error: 'Not signed in' }); return true } - user.getIdToken(false).then((idToken) => sendResponse({ idToken })).catch((e) => sendResponse({ error: String(e) })) - return true // async response - } - return undefined -}) -``` - -- [ ] **Step 6: Run the auth-bridge test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/background/auth-bridge.test.ts` -Expected: PASS (2 tests). - -- [ ] **Step 7: Commit** - -```bash -git add extension/src/background/auth-bridge.ts extension/src/background/auth-bridge.test.ts extension/src/offscreen -git commit -m "feat(extension): offscreen Firebase auth bridge" -``` - ---- - -### Task 24: `background/content-bridge.ts` — injection wrapper - -**Files:** -- Create: `extension/src/background/content-bridge.ts` -- Test: `extension/src/background/content-bridge.test.ts` - -Provides the `Injector` impl used by the dispatcher: runs page actions via `chrome.scripting.executeScript`, and tab actions via `chrome.tabs`. Includes host-permission preflight (returns `HOST_PERMISSION_REQUIRED`). - -- [ ] **Step 1: Write the failing test** - -```typescript -// extension/src/background/content-bridge.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { installChromeStub } from '../../test/chrome-stub.js' - -test('openTab requires host permission; throws HOST_PERMISSION_REQUIRED when absent', async () => { - installChromeStub({ - permissions: { contains: async () => false, request: async () => false }, - notifications: { create: () => {} }, - }) - const { createInjector } = await import('./content-bridge.js') - const inj = createInjector() - await assert.rejects(() => inj.openTab('https://amazon.com/cart'), /HOST_PERMISSION_REQUIRED/) -}) - -test('runInActiveTab returns the injected script result', async () => { - installChromeStub({ - permissions: { contains: async () => true, request: async () => true }, - tabs: { query: async () => [{ id: 7, url: 'https://x.com/a' }], create: async () => ({ id: 1 }), update: async () => ({}) }, - scripting: { executeScript: async () => [{ result: { data: { price: '$3' }, activeUrl: 'https://x.com/a' } }] }, - }) - const { createInjector } = await import('./content-bridge.js') - const inj = createInjector() - const out = await inj.runInActiveTab({ type: 'extract', selector: '.p', label: 'price' }) - assert.deepEqual(out.data, { price: '$3' }) -}) -``` - -- [ ] **Step 2: Run it to confirm it fails** - -Run: `cd extension && node --import tsx/esm --test src/background/content-bridge.test.ts` -Expected: FAIL — cannot find `./content-bridge.js`. - -- [ ] **Step 3: Write `extension/src/background/content-bridge.ts`** - -```typescript -import type { SingleAction } from '../shared/dsl-types.js' -import type { Injector } from './task-dispatcher.js' -import { runActionInPage } from '../content/executor.js' - -function originPattern(url: string): string { - try { return new URL(url).origin + '/*' } catch { return url } -} - -async function ensureHost(url: string): Promise { - const origins = [originPattern(url)] - if (await chrome.permissions.contains({ origins })) return - // Cannot call chrome.permissions.request() without a user gesture in the SW. - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon-48.png', - title: 'Clanker needs access', - message: `Clanker needs access to ${new URL(url).host}. Click to grant.`, - }) - throw new Error('HOST_PERMISSION_REQUIRED') -} - -async function activeTab(): Promise<{ id: number; url: string }> { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (!tab?.id) throw new Error('EXECUTION_ERROR: no active tab') - return { id: tab.id, url: tab.url ?? '' } -} - -export function createInjector(): Injector { - return { - async openTab(url: string) { - await ensureHost(url) - await chrome.tabs.create({ url, active: true }) - }, - async focusTab(host: string) { - const tabs = await chrome.tabs.query({}) - const match = tabs.find((t) => { try { return new URL(t.url ?? '').host === host } catch { return false } }) - if (!match?.id) throw new Error('EXECUTION_ERROR: no tab for host') - await chrome.tabs.update(match.id, { active: true }) - }, - async runInActiveTab(action: SingleAction) { - const tab = await activeTab() - if (tab.url) await ensureHost(tab.url) - const [res] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: runActionInPage as unknown as (...a: unknown[]) => unknown, - args: [action], - }) - const out = res?.result as { data: Record; activeUrl: string } | undefined - if (!out) throw new Error('EXECUTION_ERROR: empty injection result') - return out - }, - } -} -``` - -> `chrome.scripting.executeScript` with `func` serializes the function; because `runActionInPage` imports `dom-extractor`, the bundler must inline those imports into the injected function. esbuild bundles `content/executor.ts` as a separate entry (Task 16) so the injected code is self-contained at runtime — verify in Task 27 that extraction works on a real page. If serialization drops imports, switch to `files: ['content/executor.js']` injection. Note this caveat in the PR. - -- [ ] **Step 4: Run the test to confirm it passes** - -Run: `cd extension && node --import tsx/esm --test src/background/content-bridge.test.ts` -Expected: PASS (2 tests). - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/background/content-bridge.ts extension/src/background/content-bridge.test.ts -git commit -m "feat(extension): content-bridge injector + host preflight" -``` - ---- - -### Task 25: `background/service-worker.ts` — install, wake, orchestration - -**Files:** -- Create: `extension/src/background/service-worker.ts` - -Chrome-glue orchestration; verified end-to-end manually (Task 27). Structural code only, no unit test (it wires the already-tested units). - -- [ ] **Step 1: Write `extension/src/background/service-worker.ts`** - -```typescript -import { FIREBASE_SENDER_ID, CLOUD_BASE_URL, CLOUD_WS_URL } from '../env.js' -import { ensureOffscreen, requestIdToken, closeOffscreen } from './auth-bridge.js' -import { createWsClient } from './ws-client.js' -import { createInjector } from './content-bridge.js' -import { dispatchTask } from './task-dispatcher.js' - -async function getDeviceId(): Promise { - const { deviceId } = await chrome.storage.local.get('deviceId') - if (deviceId) return deviceId as string - const id = crypto.randomUUID() - await chrome.storage.local.set({ deviceId: id }) - return id -} - -async function registerDevice(gcmToken: string): Promise { - const deviceId = await getDeviceId() - const idToken = await requestIdToken().catch(() => null) - if (!idToken) return // not signed in yet; side panel will trigger registration after login - await fetch(`${CLOUD_BASE_URL}/agent/browser/register-device`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: `Bearer ${idToken}` }, - body: JSON.stringify({ fcmToken: gcmToken, deviceId, deviceName: `${navigator.platform} — Chrome` }), - }) -} - -chrome.runtime.onInstalled.addListener(() => { - chrome.gcm.register([FIREBASE_SENDER_ID], (gcmToken) => { - if (chrome.runtime.lastError) { console.error(chrome.runtime.lastError); return } - void chrome.storage.local.set({ gcmToken }) - void registerDevice(gcmToken) - }) -}) - -chrome.gcm.onMessage.addListener((message) => { - const data = message.data as { type?: string; sessionId?: string; taskId?: string } - if (data.type !== 'WAKE_AND_CONNECT' || !data.sessionId) return - void wakeAndConnect(data.sessionId) -}) - -async function wakeAndConnect(sessionId: string): Promise { - const { paused } = await chrome.storage.local.get('paused') - if (paused) return - await ensureOffscreen() - let idToken: string - try { idToken = await requestIdToken() } catch (e) { console.error('No auth for wake:', e); return } - const deviceId = await getDeviceId() - const injector = createInjector() - - const client = createWsClient({ - url: CLOUD_WS_URL, idToken, sessionId, deviceId, - onTask: (intent) => { - void (async () => { - const result = await dispatchTask(intent, injector) - await appendActionLog(intent, result.status) - client.sendResult(result) - })() - }, - onSessionEnd: () => { client.close(); void closeOffscreen() }, - }) - client.connect() -} - -async function appendActionLog(intent: { action: { type: string } }, status: string): Promise { - const { actionLog = [] } = await chrome.storage.local.get('actionLog') - const next = [{ ts: Date.now(), action: intent.action.type, status }, ...(actionLog as unknown[])].slice(0, 50) - await chrome.storage.local.set({ actionLog: next }) -} - -chrome.action?.onClicked?.addListener?.(() => { void chrome.sidePanel.open({ windowId: chrome.windows?.WINDOW_ID_CURRENT }) }) -``` - -- [ ] **Step 2: Typecheck** - -Run: `cd extension && npx tsc --noEmit` -Expected: no errors (chrome types resolve via `@types/chrome`). - -- [ ] **Step 3: Commit** - -```bash -git add extension/src/background/service-worker.ts -git commit -m "feat(extension): service worker install + wake orchestration" -``` - ---- - -### Task 26: Side panel + popup UI - -**Files:** -- Create: `extension/src/ui/side-panel/index.html`, `extension/src/ui/side-panel/panel.ts` -- Create: `extension/src/ui/popup/index.html`, `extension/src/ui/popup/popup.ts` - -Side panel: sign-in (`signInWithPopup`), account/device/status display, recent-actions log, Pause toggle, Sign Out, and a [Grant Access] button for the host-permission flow. Popup: status badge + link to side panel. - -- [ ] **Step 1: Write `extension/src/ui/side-panel/index.html`** - -```html - -Clanker Desktop Bridge - - -

Clanker Desktop Bridge

-
Status: ○ Idle
-
Account: (signed out)
-
Device: —
-
-
-
-

Recent Actions

-
    - - -``` - -- [ ] **Step 2: Write `extension/src/ui/side-panel/panel.ts`** - -```typescript -import { initializeApp } from 'firebase/app' -import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth' -import { FIREBASE_CONFIG, CLOUD_BASE_URL, FIREBASE_SENDER_ID } from '../../env.js' - -const app = initializeApp(FIREBASE_CONFIG) -const auth = getAuth(app) -const $ = (id: string) => document.getElementById(id)! - -onAuthStateChanged(auth, (user) => { - ;($('account')).textContent = `Account: ${user?.email ?? '(signed out)'}` - ;($('signin') as HTMLButtonElement).hidden = !!user - ;($('signout') as HTMLButtonElement).hidden = !user - if (user) void registerThisDevice() -}) - -$('signin').addEventListener('click', () => { void signInWithPopup(auth, new GoogleAuthProvider()) }) -$('signout').addEventListener('click', () => { void signOut(auth); void chrome.storage.local.remove('deviceId') }) - -$('pause').addEventListener('click', async () => { - const { paused } = await chrome.storage.local.get('paused') - const next = !paused - await chrome.storage.local.set({ paused: next }) - ;($('pause')).textContent = next ? 'Resume Remote Actions' : 'Pause Remote Actions' - await syncPauseToCloud(next) -}) - -$('grant').addEventListener('click', async () => { - const { pendingHost } = await chrome.storage.local.get('pendingHost') - if (pendingHost) await chrome.permissions.request({ origins: [`https://${pendingHost}/*`] }) -}) - -async function registerThisDevice(): Promise { - const idToken = await auth.currentUser!.getIdToken() - const { deviceId: existing, gcmToken } = await chrome.storage.local.get(['deviceId', 'gcmToken']) - const deviceId = (existing as string) ?? crypto.randomUUID() - if (!existing) await chrome.storage.local.set({ deviceId }) - let token = gcmToken as string | undefined - if (!token) token = await new Promise((res) => chrome.gcm.register([FIREBASE_SENDER_ID], (t) => res(t))) - await fetch(`${CLOUD_BASE_URL}/agent/browser/register-device`, { - method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${idToken}` }, - body: JSON.stringify({ fcmToken: token, deviceId, deviceName: `${navigator.platform} — Chrome` }), - }) - ;($('device')).textContent = `Device: ${navigator.platform} — Chrome` -} - -async function syncPauseToCloud(isPaused: boolean): Promise { - const user = auth.currentUser; if (!user) return - const idToken = await user.getIdToken() - const { deviceId, gcmToken } = await chrome.storage.local.get(['deviceId', 'gcmToken']) - await fetch(`${CLOUD_BASE_URL}/agent/browser/register-device`, { - method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${idToken}` }, - body: JSON.stringify({ fcmToken: gcmToken, deviceId, deviceName: `${navigator.platform} — Chrome`, isPaused }), - }) -} - -async function renderLog(): Promise { - const { actionLog = [] } = await chrome.storage.local.get('actionLog') - ;($('log')).innerHTML = (actionLog as Array<{ ts: number; action: string; status: string }>) - .map((e) => `
  • ${new Date(e.ts).toLocaleTimeString()} ${e.action} ${e.status === 'complete' ? '✓' : '✕'}
  • `).join('') -} -void renderLog() -chrome.storage.onChanged.addListener((c) => { if (c.actionLog) void renderLog() }) -``` - -- [ ] **Step 3: Write `extension/src/ui/popup/index.html`** - -```html - - - -
    Clanker Bridge
    - - - -``` - -- [ ] **Step 4: Write `extension/src/ui/popup/popup.ts`** - -```typescript -document.getElementById('open')!.addEventListener('click', () => { - void chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT }) -}) -chrome.storage.local.get(['paused']).then(({ paused }) => { - document.getElementById('badge')!.textContent = paused ? 'Clanker Bridge — Paused' : 'Clanker Bridge — Active' -}) -``` - -- [ ] **Step 5: Build the extension** - -Run: `cd extension && npm run build` -Expected: `extension built → dist/` with `background/service-worker.js`, `offscreen/auth.js`, `content/executor.js`, `ui/side-panel/panel.js`, `ui/popup/popup.js`, copied html/manifest/icons. - -- [ ] **Step 6: Commit** - -```bash -git add extension/src/ui -git commit -m "feat(extension): side panel + popup UI" -``` - ---- - -### Task 27: Phase 1 gate — build, load unpacked, manual E2E - -**Files:** none (manual verification gate). Record results in the PR description. - -> Requires real infra wired (Task 1 console steps done; `extension/src/env.ts` populated with real Firebase config, FCM Sender ID, and the deployed cloud-agent URL; cloud-agent deployed with the new code). - -- [ ] **Step 1: Extension test suite + typecheck green** - -Run: `cd extension && npm run typecheck && npm test` -Expected: all unit tests pass (`dom-extractor`, `safety-classifier`, `executor`, `task-dispatcher`, `ws-client`, `auth-bridge`, `content-bridge`). - -- [ ] **Step 2: Load unpacked** - -Open `chrome://extensions`, enable Developer mode, "Load unpacked" → `extension/dist`. Open the side panel, sign in with Google, confirm Account + Device render and a device doc appears under `users/{uid}/devices/{deviceId}` in Firestore. - -- [ ] **Step 3: E2E — text extract** - -From the Clanker text chat (`/agent/run`), with a product page open in the active tab, ask: "Extract the price from my open tab." Expected: extension wakes, executes `extract`, the price returns in chat. Confirm the task doc transitions `pending → executing → complete` in Firestore. - -- [ ] **Step 4: E2E — voice summarize** - -In a live voice call, open an article tab and ask: "What does the article say?" Expected: interim "Sent the task to your browser…" then the spoken summary. Confirm billing timer paused during the wait (no extra credit ticks while waiting). - -- [ ] **Step 5: E2E — offline path** - -Quit the browser (or toggle Pause). Ask a browser question by voice. Expected within ~12s: "Your browser extension appears to be offline." Confirm `EXTENSION_OFFLINE` task result + credit refund (voice path) in Firestore/credit ledger. - -- [ ] **Step 6: E2E — host permission grant** - -Ask Clanker to act on a site whose host has not been granted. Expected: desktop notification + side panel [Grant Access]; after granting and re-asking, the task succeeds. - -- [ ] **Step 7: Phase 1 gate sign-off** - -Confirm 5 real-world `extract` + `summarize_visible_text` tasks complete end-to-end (spec Phase 1 gate). Record the 5 runs in the PR description. - -- [ ] **Step 8: Commit any fixups + finalize** - -```bash -git add -A && git commit -m "chore(extension): Phase 1 E2E fixups" || echo "nothing to commit" -``` - ---- - -## Deferred to separate plans (out of scope here) - -- **Phase 2** — `fill_field`/`click`, two-layer halt at stateful step, `haltedStepIndex` resume, FCM approval cards, Expo Push pipeline + mobile approval UI, `auth/{taskId}` doc lifecycle, `haltForAuth`, `sendApprovalCard`/`sendTaskComplete`/`sendProactive`. -- **Phase 3** — Cloud Scheduler proactive tasks + async Expo completion. -- **Phase 4** — CWS submission: manifest `key`, policy preflight checklist, store listing. - -These each warrant their own dated plan under `docs/superpowers/plans/`. - - diff --git a/docs/superpowers/plans/2026-06-29-phase2-browser-bridge-stateful-actions.md b/docs/superpowers/plans/2026-06-29-phase2-browser-bridge-stateful-actions.md deleted file mode 100644 index c4916c0a..00000000 --- a/docs/superpowers/plans/2026-06-29-phase2-browser-bridge-stateful-actions.md +++ /dev/null @@ -1,2082 +0,0 @@ -# Phase 2 Browser Bridge — Stateful Actions & Approval Flow - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Enable stateful browser actions (`fill_field`, `click`) behind a mobile approval flow — extension halts at destructive steps, Cloud Agent fires Expo Push approval card, mobile approves, extension resumes. - -**Architecture:** Extension Layer 2 classifier halts on destructive elements → Cloud Agent writes auth doc + fires Expo Push → mobile REST-approves (Cloud Agent writes to Firestore) → Cloud Agent FCM-wakes extension with sliced resume intent. - -**Tech Stack:** `expo-notifications`, `expo-task-manager`, `@react-native-firebase/auth` (existing), Firestore Admin SDK, Firebase Cloud Messaging, Expo Push REST API, node:test (cloud-agent + extension), Jest (mobile). - -**Phase 2 gate:** Approval flow validated on staging payment form — `fill_field` + `click` submit sequenced through mobile APPROVE tap. - -**Test runners (verified):** cloud-agent + extension both have `tsx` as a devDep — run a single suite with `node --test --import tsx/esm .test.ts`. Full suites: cloud-agent `npm test` (builds to `dist/` first), extension `npm test`, mobile `npx jest`. Extension DOM tests use `jsdom` (no global `document`). Mobile has **no** `@react-native-firebase/firestore` — approval writes go through a Cloud Agent REST endpoint, not a direct client Firestore write (see Task 5). - ---- - -## File Map - -| File | Change | -|------|--------| -| `shared/dsl-types.ts` | Add `AuthDoc` type; add optional `requiresAuth: false` semantics for resume | -| `cloud-agent/src/services/firestoreSession.ts` | Add `haltForAuth`, `watchAuth` | -| `cloud-agent/src/services/firestoreSession.test.ts` | Tests for new helpers | -| `cloud-agent/src/services/fcmDispatcher.ts` | Add `sendApprovalCard`, `sendTaskComplete` | -| `cloud-agent/src/services/fcmDispatcher.test.ts` | Tests for Expo Push methods | -| `functions/drizzle/0017_expo_push_token.sql` | Postgres migration: add `expo_push_token` to `users` | -| `cloud-agent/src/db/schema.ts` | Add `expoPushToken` column to `users` table | -| `cloud-agent/src/handlers/wsBrowserAgentHandler.ts` | Handle `awaiting_auth` frame, approval flow, resume intent slicing | -| `cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts` | Tests for approval flow | -| `cloud-agent/src/index.ts` | Add `POST /agent/browser/approve-action` endpoint; wire `getExpoPushToken` dep | -| `cloud-agent/src/tools/browserAction.ts` | Voice narration on `awaiting_auth` status | -| `cloud-agent/src/tools/browserAction.test.ts` | Test awaiting_auth voice narration | -| `extension/src/content/executor.ts` | Implement `fill_field`, `click`; wire `classifyElement` (Layer 2) | -| `extension/src/content/executor.test.ts` | Tests for stateful actions + classifier | -| `extension/src/background/task-dispatcher.ts` | New `DispatchOutcome` type; halt on AWAITING_AUTH | -| `extension/src/background/task-dispatcher.test.ts` | Tests for halt path | -| `extension/src/background/ws-client.ts` | Add `sendAwaitingAuth` | -| `extension/src/background/ws-client.test.ts` | Test new frame | -| `extension/src/background/service-worker.ts` | Handle `awaiting_auth` outcome; close WS and suspend | -| `src/hooks/useRegisterExpoPushToken.ts` | New: register Expo push token on sign-in | -| `src/hooks/useBrowserActionApproval.ts` | New: background task def + notification category setup | -| `app/_layout.tsx` | Wire push token + notification hooks | -| `firestore.rules` | Update auth doc write rules | - ---- - -### Task 1: AuthDoc Type + Firestore Phase 2 Helpers - -**Files:** -- Modify: `shared/dsl-types.ts` -- Modify: `cloud-agent/src/services/firestoreSession.ts` -- Modify: `cloud-agent/src/services/firestoreSession.test.ts` - -- [ ] **Step 1: Add `AuthDoc` to `shared/dsl-types.ts`** - -Add after the `DeviceDoc` export: - -```typescript -export type AuthStatus = 'pending' | 'approved' | 'denied' - -export interface AuthDoc { - status: AuthStatus - actionSummary: string - expiresAt: unknown // Firestore Timestamp - approvedAt: unknown | null - approvalToken: string | null -} -``` - -- [ ] **Step 2: Write failing tests for `haltForAuth` and `watchAuth`** - -In `cloud-agent/src/services/firestoreSession.test.ts`, add after the existing tests: - -```typescript -test('haltForAuth writes task awaiting_auth + session pending_auth + auth doc pending', async () => { - const calls: Array<{ path: string; data: Record; opts?: unknown }> = [] - const db = makeFakeDb(calls) - const fs = createFirestoreSession(db) - - await fs.haltForAuth('uid1', 'sid1', 'tid1', 2, 'Submit payment') - - const taskCall = calls.find((c) => c.path === 'users/uid1/sessions/sid1/tasks/tid1') - const sessionCall = calls.find((c) => c.path === 'users/uid1/sessions/sid1') - const authCall = calls.find((c) => c.path === 'users/uid1/sessions/sid1/auth/tid1') - - assert.equal(taskCall?.data.status, 'awaiting_auth') - assert.equal(taskCall?.data.haltedStepIndex, 2) - assert.equal(sessionCall?.data.status, 'pending_auth') - assert.equal(authCall?.data.status, 'pending') - assert.equal(authCall?.data.actionSummary, 'Submit payment') - assert.ok(authCall?.data.expiresAt) -}) - -test('watchAuth calls callback when auth doc snapshot fires', async () => { - let snapCb: ((s: { exists: boolean; data(): Record }) => void) | null = null - const db = { - doc: (path: string) => ({ - set: async () => {}, - get: async () => ({ exists: false, data: () => undefined }), - update: async () => {}, - onSnapshot: (cb: typeof snapCb) => { snapCb = cb; return () => {} }, - }), - collection: (_path: string) => ({ where: () => ({ orderBy: () => ({ limit: () => ({ get: async () => ({ empty: true, docs: [] }) }) }) }) }, - } as unknown as import('./firestoreSession.js').FirestoreLike - - const fs = createFirestoreSession(db) - const received: unknown[] = [] - const unsub = fs.watchAuth('uid1', 'sid1', 'tid1', (auth) => received.push(auth)) - - snapCb!({ exists: true, data: () => ({ status: 'approved', approvalToken: 'tok', approvedAt: null, actionSummary: 'x', expiresAt: 0 }) }) - assert.equal(received.length, 1) - unsub() -}) -``` - -- [ ] **Step 3: Run tests to confirm they fail** - -```bash -cd cloud-agent && node --test --import tsx/esm src/services/firestoreSession.test.ts -``` - -Expected: FAIL — `fs.haltForAuth is not a function`, `fs.watchAuth is not a function`. - -- [ ] **Step 4: Implement `haltForAuth` and `watchAuth` in `firestoreSession.ts`** - -Add to the return object of `createFirestoreSession`: - -```typescript -async haltForAuth(uid: string, sid: string, tid: string, haltedStepIndex: number, actionSummary: string): Promise { - const AUTH_TTL_MS = 5 * 60 * 1000 - const authPath = `users/${uid}/sessions/${sid}/auth/${tid}` - const expiresAt = admin.firestore?.Timestamp - ? admin.firestore.Timestamp.fromMillis(Date.now() + AUTH_TTL_MS) - : (Date.now() + AUTH_TTL_MS as unknown) - - if (db.batch) { - const batch = db.batch() - batch.update(taskPath(uid, sid, tid), { status: 'awaiting_auth', haltedStepIndex, updatedAt: now() }) - batch.update(sessionPath(uid, sid), { status: 'pending_auth' }) - await batch.commit() - } else { - await db.doc(taskPath(uid, sid, tid)).update({ status: 'awaiting_auth', haltedStepIndex, updatedAt: now() }) - await db.doc(sessionPath(uid, sid)).update({ status: 'pending_auth' }) - } - await db.doc(authPath).set({ - status: 'pending', actionSummary, expiresAt, - approvedAt: null, approvalToken: null, - }) -}, - -watchAuth(uid: string, sid: string, tid: string, cb: (auth: import('../../../shared/dsl-types.js').AuthDoc) => void): () => void { - const authPath = `users/${uid}/sessions/${sid}/auth/${tid}` - const ref = db.doc(authPath) - if (!ref.onSnapshot) throw new Error('watchAuth requires onSnapshot support') - return ref.onSnapshot((snap) => { - if (snap.exists) cb(snap.data() as unknown as import('../../../shared/dsl-types.js').AuthDoc) - }) -}, -``` - -Also add `AuthDoc` to the imports from `dsl-types.ts`: - -```typescript -import type { TaskIntent, TaskResult, SessionDoc, TaskDoc, DeviceDoc, AuthDoc } from '../../../shared/dsl-types.js' -``` - -And update the `FirestoreSession` type export at the end of the file (no action needed — `ReturnType` picks it up automatically). - -- [ ] **Step 5: Run tests and verify pass** - -```bash -cd cloud-agent && node --test --import tsx/esm src/services/firestoreSession.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add shared/dsl-types.ts cloud-agent/src/services/firestoreSession.ts cloud-agent/src/services/firestoreSession.test.ts -git commit -m "feat(bridge/p2): add AuthDoc type and haltForAuth/watchAuth to firestoreSession" -``` - ---- - -### Task 2: FCM Dispatcher — Expo Push Methods - -**Files:** -- Modify: `cloud-agent/src/services/fcmDispatcher.ts` -- Modify: `cloud-agent/src/services/fcmDispatcher.test.ts` - -- [ ] **Step 1: Write failing tests for `sendApprovalCard` and `sendTaskComplete`** - -Add to `cloud-agent/src/services/fcmDispatcher.test.ts`: - -```typescript -test('sendApprovalCard POSTs correct Expo Push payload', async () => { - const fetched: Array<{ url: string; body: unknown }> = [] - const fakeFetch = async (url: string, opts: RequestInit) => { - fetched.push({ url, body: JSON.parse(opts.body as string) }) - return { ok: true, json: async () => ({ data: [{ status: 'ok' }] }) } - } - - const { createFcmDispatcher } = await import('./fcmDispatcher.js') - const dispatcher = createFcmDispatcher( - { send: async () => 'msg-id' }, - fakeFetch as unknown as typeof fetch, - ) - - await dispatcher.sendApprovalCard('ExponentPushToken[abc]', 'sid1', 'tid1', 'Submit $42') - - assert.equal(fetched.length, 1) - assert.equal(fetched[0].url, 'https://exp.host/--/api/v2/push/send') - const body = fetched[0].body as Record - assert.equal(body.to, 'ExponentPushToken[abc]') - assert.equal(body.categoryIdentifier, 'BROWSER_ACTION_APPROVAL') - assert.equal((body.data as Record).sessionId, 'sid1') - assert.equal((body.data as Record).taskId, 'tid1') - assert.equal(body.ttl, 300) -}) - -test('sendTaskComplete POSTs correct Expo Push payload', async () => { - const fetched: Array<{ body: unknown }> = [] - const fakeFetch = async (_url: string, opts: RequestInit) => { - fetched.push({ body: JSON.parse(opts.body as string) }) - return { ok: true, json: async () => ({ data: [{ status: 'ok' }] }) } - } - - const { createFcmDispatcher } = await import('./fcmDispatcher.js') - const dispatcher = createFcmDispatcher( - { send: async () => 'msg-id' }, - fakeFetch as unknown as typeof fetch, - ) - - await dispatcher.sendTaskComplete('ExponentPushToken[xyz]', 'tid1', 'Article summary ready.') - - const body = fetched[0].body as Record - assert.equal(body.to, 'ExponentPushToken[xyz]') - assert.equal(body.priority, 'normal') -}) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd cloud-agent && node --test --import tsx/esm src/services/fcmDispatcher.test.ts -``` - -Expected: FAIL — `sendApprovalCard is not a function`. - -- [ ] **Step 3: Implement Expo Push methods in `fcmDispatcher.ts`** - -Replace the entire file: - -```typescript -import admin from 'firebase-admin' - -export interface MessagingLike { - send(message: { token: string; data: Record }): Promise -} - -const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send' - -export function createFcmDispatcher(messaging: MessagingLike, fetchImpl: typeof fetch = fetch) { - async function expoPush(payload: Record): Promise { - const res = await fetchImpl(EXPO_PUSH_URL, { - method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json' }, - body: JSON.stringify(payload), - }) - if (!res.ok) throw new Error(`Expo Push failed: ${res.status}`) - } - - return { - async wakeExtension(fcmToken: string, sessionId: string, taskId: string, resume = false): Promise { - await messaging.send({ - token: fcmToken, - data: { type: 'WAKE_AND_CONNECT', sessionId, taskId, resume: String(resume) }, - }) - }, - - async sendApprovalCard(expoPushToken: string, sessionId: string, taskId: string, actionSummary: string): Promise { - await expoPush({ - to: expoPushToken, - title: 'Clanker needs your approval', - body: actionSummary, - data: { type: 'PENDING_AUTH', sessionId, taskId, actionSummary }, - categoryIdentifier: 'BROWSER_ACTION_APPROVAL', - priority: 'high', - ttl: 300, - }) - }, - - async sendTaskComplete(expoPushToken: string, taskId: string, summary: string): Promise { - await expoPush({ - to: expoPushToken, - title: 'Clanker finished', - body: summary, - data: { type: 'TASK_COMPLETE', taskId }, - priority: 'normal', - }) - }, - } -} - -export type FcmDispatcher = ReturnType - -export function defaultFcmDispatcher(): FcmDispatcher { - return createFcmDispatcher(admin.messaging() as unknown as MessagingLike) -} -``` - -- [ ] **Step 4: Run tests and verify pass** - -```bash -cd cloud-agent && node --test --import tsx/esm src/services/fcmDispatcher.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/services/fcmDispatcher.ts cloud-agent/src/services/fcmDispatcher.test.ts -git commit -m "feat(bridge/p2): add sendApprovalCard and sendTaskComplete to fcmDispatcher" -``` - ---- - -### Task 3: Expo Push Token Storage - -**Files:** -- Create: `functions/drizzle/0017_expo_push_token.sql` -- Modify: `cloud-agent/src/db/schema.ts` -- Modify: `cloud-agent/src/index.ts` - -- [ ] **Step 1: Write the Postgres migration** - -Create `functions/drizzle/0017_expo_push_token.sql`: - -```sql -ALTER TABLE "users" ADD COLUMN "expo_push_token" text; -``` - -- [ ] **Step 2: Update `cloud-agent/src/db/schema.ts`** - -Add `expoPushToken` to the `users` table: - -```typescript -export const users = pgTable('users', { - id: uuid('id').primaryKey().defaultRandom(), - firebaseUid: text('firebase_uid').unique().notNull(), - email: text('email').unique().notNull(), - displayName: text('display_name'), - expoPushToken: text('expo_push_token'), - createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), -}) -``` - -- [ ] **Step 3: Write test for `POST /agent/user/expo-push-token` endpoint** - -In `cloud-agent/src/index.test.ts` (or a new `expoPushToken.test.ts`), add: - -```typescript -// cloud-agent/src/handlers/expoPushToken.test.ts -import test from 'node:test' -import assert from 'node:assert/strict' -import { upsertExpoPushToken } from './expoPushToken.js' - -test('upsertExpoPushToken updates user row', async () => { - const updates: Array<{ uid: string; token: string }> = [] - const fakeDb = { - update: () => ({ - set: (data: Record) => ({ - where: (cond: unknown) => { - updates.push({ uid: String(cond), token: data.expoPushToken as string }) - return Promise.resolve() - }, - }), - }), - } - await upsertExpoPushToken(fakeDb as never, 'firebase-uid-1', 'ExponentPushToken[abc]') - assert.equal(updates.length, 1) - assert.equal(updates[0].token, 'ExponentPushToken[abc]') -}) -``` - -- [ ] **Step 4: Create `cloud-agent/src/handlers/expoPushToken.ts`** - -```typescript -import { eq } from 'drizzle-orm' -import type { NodePgDatabase } from 'drizzle-orm/node-postgres' -import { users } from '../db/schema.js' -import type * as schema from '../db/schema.js' - -export async function upsertExpoPushToken( - db: NodePgDatabase, - firebaseUid: string, - expoPushToken: string, -): Promise { - await db.update(users).set({ expoPushToken }).where(eq(users.firebaseUid, firebaseUid)) -} - -export async function getExpoPushToken( - db: NodePgDatabase, - firebaseUid: string, -): Promise { - const rows = await db.select({ expoPushToken: users.expoPushToken }) - .from(users) - .where(eq(users.firebaseUid, firebaseUid)) - .limit(1) - return rows[0]?.expoPushToken ?? null -} -``` - -- [ ] **Step 5: Run test** - -```bash -cd cloud-agent && node --test --import tsx/esm src/handlers/expoPushToken.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Register endpoint in `cloud-agent/src/index.ts`** - -Import the new handler: - -```typescript -import { upsertExpoPushToken, getExpoPushToken } from './handlers/expoPushToken.js' -``` - -Add endpoint (alongside `/agent/browser/register-device`): - -```typescript -app.post('/agent/user/expo-push-token', requireAuth, async (req: Request & { uid?: string }, res: Response): Promise => { - const parsed = z.object({ expoPushToken: z.string().min(1) }).safeParse(req.body) - if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return } - try { - await upsertExpoPushToken(db, req.uid!, parsed.data.expoPushToken) - res.json({ ok: true }) - } catch (err) { - console.error('expo-push-token upsert error:', err) - res.status(500).json({ error: 'Internal server error' }) - } -}) -``` - -Also make `getExpoPushToken` accessible as a dep (export it from this module scope): - -```typescript -// near the top of attachBrowserWsRoutes or wherever wsHandlerOptions is built: -const getExpoPushTokenForUser = (firebaseUid: string) => getExpoPushToken(db, firebaseUid) -// pass as: wsHandlerOptions.getExpoPushToken = getExpoPushTokenForUser -``` - -(Exact wiring covered in Task 4.) - -- [ ] **Step 7: Commit** - -```bash -git add functions/drizzle/0017_expo_push_token.sql cloud-agent/src/db/schema.ts \ - cloud-agent/src/handlers/expoPushToken.ts cloud-agent/src/handlers/expoPushToken.test.ts \ - cloud-agent/src/index.ts -git commit -m "feat(bridge/p2): add expo_push_token column, upsert endpoint, and getExpoPushToken helper" -``` - ---- - -### Task 4: wsBrowserAgentHandler — awaiting_auth Frame + Approval Flow - -**Files:** -- Modify: `cloud-agent/src/handlers/wsBrowserAgentHandler.ts` -- Modify: `cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts` - -- [ ] **Step 1: Write failing tests for the approval flow** - -Add to `cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts`: - -```typescript -test('awaiting_auth frame calls haltForAuth and sendApprovalCard', async () => { - const ws = new FakeWs() - const calls: Record = { halt: [], approval: [] } - const pendingIntent = { - version: '1', taskId: 't1', sessionId: SESSION_ID, requiresAuth: true, - actionSummary: 'Submit payment', action: { type: 'sequence', steps: [ - { type: 'open_tab', url: 'https://shop.com' }, - { type: 'click', selector: '#buy', label: 'Buy Now', tier: 'stateful' }, - ] }, - } - const { options } = deps({ - firestoreSession: { - getSession: async () => ({ status: 'pending' }), - getFirstTask: async () => ({ status: 'pending', intent: pendingIntent }), - getTask: async () => ({ status: 'pending', intent: pendingIntent }), - markBrowserConnected: async () => {}, - writeTaskResult: async () => {}, - closeSession: async () => {}, - haltForAuth: async (...a: unknown[]) => { calls.halt.push(a) }, - watchAuth: () => () => {}, - }, - fcmDispatcher: { - wakeExtension: async () => {}, - sendApprovalCard: async (...a: unknown[]) => { calls.approval.push(a) }, - sendTaskComplete: async () => {}, - }, - getExpoPushToken: async () => 'ExponentPushToken[mobile]', - }) - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - ws.emitJson({ type: 'auth', idToken: 'tok', sessionId: SESSION_ID, deviceId: 'd1' }) - await new Promise((r) => setTimeout(r, 20)) - ws.emitJson({ type: 'awaiting_auth', taskId: 't1', haltedStepIndex: 1 }) - await new Promise((r) => setTimeout(r, 20)) - assert.equal(calls.halt.length, 1) - assert.equal(calls.approval.length, 1) - const [, , , haltIdx, summary] = calls.halt[0] as [string, string, string, number, string] - assert.equal(haltIdx, 1) - assert.equal(summary, 'Submit payment') -}) - -test('watchAuth approved → verifies token → sends FCM wake with resume', async () => { - const ws = new FakeWs() - let authWatcher: ((auth: Record) => void) | null = null - const fcmWakes: unknown[] = [] - const verifyTokenCalls: string[] = [] - - const pendingIntent = { - version: '1', taskId: 't1', sessionId: SESSION_ID, requiresAuth: true, - actionSummary: 'Submit payment', action: { type: 'sequence', steps: [ - { type: 'click', selector: '#buy', tier: 'stateful' }, - ] }, - } - const { options } = deps({ - verifyToken: async (t: string) => { verifyTokenCalls.push(t); return { uid: 'fb-uid' } }, - firestoreSession: { - getSession: async () => ({ status: 'pending' }), - getFirstTask: async () => ({ status: 'pending', intent: pendingIntent }), - getTask: async () => ({ status: 'pending', intent: pendingIntent }), - markBrowserConnected: async () => {}, - writeTaskResult: async () => {}, - closeSession: async () => {}, - haltForAuth: async () => {}, - watchAuth: (_u: string, _s: string, _t: string, cb: (a: Record) => void) => { - authWatcher = cb; return () => {} - }, - }, - fcmDispatcher: { - wakeExtension: async (...a: unknown[]) => { fcmWakes.push(a) }, - sendApprovalCard: async () => {}, - sendTaskComplete: async () => {}, - }, - getExpoPushToken: async () => 'ExponentPushToken[mobile]', - getDeviceFcmToken: async () => 'gcm-tok-123', - }) - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - ws.emitJson({ type: 'auth', idToken: 'tok', sessionId: SESSION_ID, deviceId: 'd1' }) - await new Promise((r) => setTimeout(r, 20)) - ws.emitJson({ type: 'awaiting_auth', taskId: 't1', haltedStepIndex: 0 }) - await new Promise((r) => setTimeout(r, 20)) - - authWatcher!({ status: 'approved', approvalToken: 'approval-id-token', approvedAt: null, expiresAt: 0, actionSummary: '' }) - await new Promise((r) => setTimeout(r, 20)) - - assert.ok(verifyTokenCalls.includes('approval-id-token')) - assert.equal(fcmWakes.length, 1) - const [, , , resume] = fcmWakes[0] as [string, string, string, boolean] - assert.equal(resume, true) -}) - -test('watchAuth denied → aborts task and sends session_end', async () => { - const ws = new FakeWs() - let authWatcher: ((auth: Record) => void) | null = null - const results: unknown[] = [] - - const pendingIntent = { - version: '1', taskId: 't1', sessionId: SESSION_ID, requiresAuth: true, - actionSummary: 'Submit', action: { type: 'click', selector: '#s', tier: 'stateful' }, - } - const { options } = deps({ - firestoreSession: { - getSession: async () => ({ status: 'pending' }), - getFirstTask: async () => ({ status: 'pending', intent: pendingIntent }), - getTask: async () => ({ status: 'pending', intent: pendingIntent }), - markBrowserConnected: async () => {}, - writeTaskResult: async (...a: unknown[]) => { results.push(a) }, - closeSession: async () => {}, - haltForAuth: async () => {}, - watchAuth: (_u: string, _s: string, _t: string, cb: (a: Record) => void) => { - authWatcher = cb; return () => {} - }, - }, - fcmDispatcher: { wakeExtension: async () => {}, sendApprovalCard: async () => {}, sendTaskComplete: async () => {} }, - getExpoPushToken: async () => null, - getDeviceFcmToken: async () => null, - }) - handleBrowserWsUpgrade(ws as never, {} as never, options as never) - ws.emitJson({ type: 'auth', idToken: 'tok', sessionId: SESSION_ID, deviceId: 'd1' }) - await new Promise((r) => setTimeout(r, 20)) - ws.emitJson({ type: 'awaiting_auth', taskId: 't1', haltedStepIndex: 0 }) - await new Promise((r) => setTimeout(r, 20)) - - authWatcher!({ status: 'denied', approvalToken: null, approvedAt: null, expiresAt: 0, actionSummary: '' }) - await new Promise((r) => setTimeout(r, 20)) - - const sent = ws.sent.map((s: string) => JSON.parse(s) as { type: string }) - assert.ok(sent.some((s) => s.type === 'session_end')) - const writeResult = (results[0] as unknown[])[3] as { status: string } - assert.equal(writeResult.status, 'aborted') -}) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd cloud-agent && node --test --import tsx/esm src/handlers/wsBrowserAgentHandler.test.ts -``` - -Expected: FAIL — awaiting_auth handler not implemented. - -- [ ] **Step 3: Implement `awaiting_auth` handling in `wsBrowserAgentHandler.ts`** - -Replace the file with: - -```typescript -import type { WebSocket } from 'ws' -import type { IncomingMessage } from 'http' -import admin from 'firebase-admin' -import { z } from 'zod' -import type { FirestoreSession } from '../services/firestoreSession.js' -import type { FcmDispatcher } from '../services/fcmDispatcher.js' -import { sessionBridge } from '../services/sessionBridge.js' -import type { TaskResult, TaskIntent } from '../../../shared/dsl-types.js' -import { taskErrorFrameSchema } from '../../../shared/dsl-schema.js' - -const browserAuthSchema = z.object({ - type: z.literal('auth'), - idToken: z.string().min(1), - sessionId: z.string().uuid(), - deviceId: z.string().min(1), -}) - -const resultFrameSchema = z.object({ - type: z.literal('task_result'), - taskId: z.string(), - data: z.record(z.string(), z.string()), - activeUrl: z.string(), -}) - -const awaitingAuthFrameSchema = z.object({ - type: z.literal('awaiting_auth'), - taskId: z.string(), - haltedStepIndex: z.number().int().nonnegative(), -}) - -export interface BrowserWsOptions { - firestoreSession: FirestoreSession - fcmDispatcher?: FcmDispatcher - verifyToken?: (token: string) => Promise<{ uid: string }> - resolveUserId?: (firebaseUid: string) => Promise - validateDevice?: (firebaseUid: string, deviceId: string) => Promise - getDeviceFcmToken?: (uid: string, deviceId: string) => Promise - getExpoPushToken?: (uid: string) => Promise - instanceId: string - authTimeoutMs?: number -} - -export function handleBrowserWsUpgrade( - ws: WebSocket, - _req: IncomingMessage, - options: BrowserWsOptions, -): void { - const verifyToken = options.verifyToken ?? - ((t: string) => admin.auth().verifyIdToken(t).then((d) => ({ uid: d.uid }))) - const resolveUserId = options.resolveUserId ?? (async (u: string) => u) - const validateDevice = options.validateDevice ?? (async () => true) - const fs = options.firestoreSession - const fwd = options.fcmDispatcher - const authTimeoutMs = options.authTimeoutMs ?? 5000 - - let authed = false - let firebaseUid: string | null = null - let sessionId: string | null = null - let deviceId: string | null = null - let dispatchedIntent: TaskIntent | null = null - let authUnsub: (() => void) | null = null - let isResume = false // true when this WS connection resumes an approved, previously-halted task - - const authTimer = setTimeout(() => { - if (!authed && ws.readyState === ws.OPEN) ws.close(4001, 'Auth timeout') - }, authTimeoutMs) - - async function onAuth(raw: unknown): Promise { - const parsed = browserAuthSchema.safeParse(raw) - if (!parsed.success) { ws.close(4001, 'Invalid auth frame'); return } - const { idToken, sessionId: sid, deviceId: did } = parsed.data - let fbUid: string - try { fbUid = (await verifyToken(idToken)).uid } catch { ws.close(4001, 'Token verification failed'); return } - const resolved = await resolveUserId(fbUid) - if (!resolved) { ws.close(4001, 'User not found'); return } - if (!(await validateDevice(resolved, did))) { ws.close(4001, 'Unknown device'); return } - - const session = await fs.getSession(resolved, sid) - if (session.status === 'closed' || session.status === 'aborted') { - ws.close(4001, 'Session closed') - return - } - - firebaseUid = resolved; sessionId = sid; deviceId = did; authed = true - clearTimeout(authTimer) - - const pendingTask = await fs.getFirstTask(firebaseUid, sid) - if (!pendingTask) { ws.close(4001, 'No pending task'); return } - - dispatchedIntent = pendingTask.intent - - // On resume from approval, slice sequence to start at haltedStepIndex with requiresAuth:false - let resumeIntent = pendingTask.intent - if (pendingTask.status === 'awaiting_auth' && pendingTask.haltedStepIndex != null) { - isResume = true - const orig = pendingTask.intent.action - if (orig.type === 'sequence') { - resumeIntent = { - ...pendingTask.intent, - requiresAuth: false, // approved — extension Layer 2 skips for first step - action: { type: 'sequence', steps: orig.steps.slice(pendingTask.haltedStepIndex) }, - } - } - } - - await fs.markBrowserConnected(firebaseUid, sid, options.instanceId, pendingTask.intent.taskId) - sessionBridge.registerBrowser(firebaseUid, sid, ws) - ws.send(JSON.stringify({ type: 'session_ready', sessionId: sid })) - ws.send(JSON.stringify({ type: 'task', intent: resumeIntent })) - } - - async function onResult(raw: unknown): Promise { - if (!authed || !firebaseUid || !sessionId) return - const r = resultFrameSchema.safeParse(raw) - if (r.success) { - const result: TaskResult = { taskId: r.data.taskId, status: 'complete', data: r.data.data, activeUrl: r.data.activeUrl } - await fs.writeTaskResult(firebaseUid, sessionId, r.data.taskId, result) - // Async delivery: the voice/text turn already ended at the approval pause, - // so push the completed result to the user's phone (decision: teardown + async push). - if (isResume && fwd && options.getExpoPushToken) { - const expoPushToken = await options.getExpoPushToken(firebaseUid) - if (expoPushToken) { - await fwd.sendTaskComplete(expoPushToken, r.data.taskId, 'Your browser task finished.').catch( - (err) => console.error('sendTaskComplete failed:', err) - ) - } - } - ws.send(JSON.stringify({ type: 'session_end' })) - return - } - const e = taskErrorFrameSchema.safeParse(raw) - if (e.success) { - const result: TaskResult = { - taskId: e.data.taskId, status: 'failed', data: {}, activeUrl: '', - error: { code: e.data.code, message: e.data.message, failedAction: e.data.failedAction }, - } - await fs.writeTaskResult(firebaseUid, sessionId, e.data.taskId, result) - ws.send(JSON.stringify({ type: 'session_end' })) - } - } - - async function onAwaitingAuth(raw: unknown): Promise { - if (!authed || !firebaseUid || !sessionId || !dispatchedIntent) return - const parsed = awaitingAuthFrameSchema.safeParse(raw) - if (!parsed.success) return - const { taskId, haltedStepIndex } = parsed.data - const actionSummary = dispatchedIntent.actionSummary - - // Write awaiting_auth to Firestore - await fs.haltForAuth(firebaseUid, sessionId, taskId, haltedStepIndex, actionSummary) - - // Fire Expo Push approval card (best effort) - if (fwd && options.getExpoPushToken) { - const expoPushToken = await options.getExpoPushToken(firebaseUid) - if (expoPushToken) { - await fwd.sendApprovalCard(expoPushToken, sessionId, taskId, actionSummary).catch( - (err) => console.error('sendApprovalCard failed:', err) - ) - } - } - - // Store device FCM token for re-wake - const deviceFcmToken = options.getDeviceFcmToken - ? await options.getDeviceFcmToken(firebaseUid, deviceId!) - : null - - // Watch auth doc — resolve approval or denial - authUnsub = fs.watchAuth(firebaseUid, sessionId, taskId, async (auth) => { - if (auth.status === 'pending') return - - authUnsub?.() - authUnsub = null - - if (auth.status === 'approved') { - // Verify approvalToken — prove the correct user tapped Approve - try { - const decoded = await verifyToken(auth.approvalToken ?? '') - if (decoded.uid !== firebaseUid) throw new Error('UID mismatch') - } catch { - // Invalid token → treat as denied - await fs.writeTaskResult(firebaseUid!, sessionId!, taskId, { - taskId, status: 'aborted', data: {}, activeUrl: '', - error: { code: 'AUTH_TIMEOUT', message: 'Approval token invalid', failedAction: dispatchedIntent!.action as never }, - }) - ws.send(JSON.stringify({ type: 'session_end' })) - return - } - - // Re-wake extension via FCM (resume: true) - if (fwd && deviceFcmToken) { - await fwd.wakeExtension(deviceFcmToken, sessionId!, taskId, true).catch( - (err) => console.error('FCM resume wake failed:', err) - ) - } - // Extension will reconnect with a new WS; this socket can close - ws.send(JSON.stringify({ type: 'session_end' })) - } else { - // Denied - await fs.writeTaskResult(firebaseUid!, sessionId!, taskId, { - taskId, status: 'aborted', data: {}, activeUrl: '', - error: { code: 'AUTH_TIMEOUT', message: 'Action was denied', failedAction: dispatchedIntent!.action as never }, - }) - ws.send(JSON.stringify({ type: 'session_end' })) - } - }) - } - - ws.on('message', (data: Buffer) => { - let parsed: unknown - try { parsed = JSON.parse(data.toString()) } catch { return } - const type = (parsed as { type?: string }).type - if (type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); return } - if (!authed) { - void onAuth(parsed).catch(() => ws.close(1011, 'Internal error')) - return - } - if (type === 'task_result' || type === 'task_error') { - void onResult(parsed).catch(() => ws.close(1011, 'Internal error')) - return - } - if (type === 'awaiting_auth') { - void onAwaitingAuth(parsed).catch(() => ws.close(1011, 'Internal error')) - return - } - }) - - ws.on('close', () => { - clearTimeout(authTimer) - authUnsub?.() - if (firebaseUid && sessionId) sessionBridge.deregisterBrowser(firebaseUid, sessionId) - }) - ws.on('error', () => { clearTimeout(authTimer) }) -} -``` - -- [ ] **Step 4: Wire deps in `cloud-agent/src/index.ts`** - -In `attachWebSocketRoutes`, update the browser WS handler options: - -```typescript -} else if (pathname === '/agent/browser') { - if (!browserBridgeAvailable) { socket.destroy(); return } - browserWss.handleUpgrade(req, socket, head, (ws) => { - handleBrowserWsUpgrade(ws, req, { - firestoreSession: defaultFirestoreSession(), - fcmDispatcher: defaultFcmDispatcher(), - verifyToken, - resolveUserId: async (fbUid: string) => { - const rows = await db.select({ id: users.firebaseUid }).from(users) - .where(eq(users.firebaseUid, fbUid)).limit(1) - return rows[0]?.id ?? null - }, - getExpoPushToken: (firebaseUid: string) => getExpoPushToken(db, firebaseUid), - getDeviceFcmToken: async (uid: string, deviceId: string) => { - const snap = await admin.firestore().doc(`users/${uid}/devices/${deviceId}`).get() - if (!snap.exists) return null - return (snap.data()?.fcmToken as string) ?? null - }, - instanceId: INSTANCE_ID, - }) - }) -} -``` - -Add required imports at the top of `index.ts`: - -```typescript -import { getExpoPushToken } from './handlers/expoPushToken.js' -import { eq } from 'drizzle-orm' -import { users } from './db/schema.js' -``` - -- [ ] **Step 5: Run tests and verify pass** - -```bash -cd cloud-agent && node --test --import tsx/esm src/handlers/wsBrowserAgentHandler.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add cloud-agent/src/handlers/wsBrowserAgentHandler.ts \ - cloud-agent/src/handlers/wsBrowserAgentHandler.test.ts \ - cloud-agent/src/index.ts -git commit -m "feat(bridge/p2): handle awaiting_auth frame — haltForAuth, Expo Push, watchAuth approval loop" -``` - ---- - -### Task 5: Cloud Agent Approve-Action Endpoint - -> **Spec deviation (intentional):** The spec shows the mobile app writing the approval decision *directly* to the Firestore auth doc. The mobile app does **not** depend on `@react-native-firebase/firestore` (verified — only `auth`, `app`, `app-check`, `functions`, `crashlytics`). Adding a Firestore client SDK to mobile for one write is unjustified. Instead, mobile POSTs to this Cloud Agent endpoint (behind existing `requireAuth`), and the Admin SDK performs the auth-doc write. The Firestore rules in Task 12 remain as defense-in-depth for any future direct-write path but are not the write path used here. - -**Files:** -- Modify: `cloud-agent/src/index.ts` - -- [ ] **Step 1: Write test for `POST /agent/browser/approve-action`** - -Create `cloud-agent/src/handlers/approveAction.test.ts`: - -```typescript -import test from 'node:test' -import assert from 'node:assert/strict' -import { handleApproveAction } from './approveAction.js' - -test('handleApproveAction writes approved to auth doc', async () => { - const updates: Array<{ path: string; data: Record }> = [] - const fakeDb = { - doc: (path: string) => ({ - update: async (data: Record) => { updates.push({ path, data }) }, - }), - } - - await handleApproveAction(fakeDb as never, 'uid1', { - sessionId: 'sid1', taskId: 'tid1', approve: true, idToken: 'raw-token', - }) - - assert.equal(updates.length, 1) - assert.equal(updates[0].path, 'users/uid1/sessions/sid1/auth/tid1') - assert.equal(updates[0].data.status, 'approved') - assert.equal(updates[0].data.approvalToken, 'raw-token') -}) - -test('handleApproveAction writes denied to auth doc', async () => { - const updates: Array<{ path: string; data: Record }> = [] - const fakeDb = { - doc: (path: string) => ({ - update: async (data: Record) => { updates.push({ path, data }) }, - }), - } - - await handleApproveAction(fakeDb as never, 'uid1', { - sessionId: 'sid1', taskId: 'tid1', approve: false, idToken: '', - }) - - assert.equal(updates[0].data.status, 'denied') -}) -``` - -- [ ] **Step 2: Create `cloud-agent/src/handlers/approveAction.ts`** - -```typescript -import admin from 'firebase-admin' - -interface ApproveActionBody { - sessionId: string - taskId: string - approve: boolean - idToken: string -} - -interface FirestoreLite { - doc(path: string): { update(data: Record): Promise } -} - -export async function handleApproveAction( - db: FirestoreLite, - uid: string, - body: ApproveActionBody, -): Promise { - const authPath = `users/${uid}/sessions/${body.sessionId}/auth/${body.taskId}` - if (body.approve) { - await db.doc(authPath).update({ - status: 'approved', - approvalToken: body.idToken, - approvedAt: admin.firestore?.Timestamp?.now?.() ?? new Date(), - }) - } else { - await db.doc(authPath).update({ status: 'denied' }) - } -} -``` - -- [ ] **Step 3: Run test** - -```bash -cd cloud-agent && node --test --import tsx/esm src/handlers/approveAction.test.ts -``` - -Expected: PASS. - -- [ ] **Step 4: Register endpoint in `cloud-agent/src/index.ts`** - -```typescript -import { handleApproveAction } from './handlers/approveAction.js' -``` - -Add endpoint: - -```typescript -app.post('/agent/browser/approve-action', requireAuth, async (req: Request & { uid?: string }, res: Response): Promise => { - const parsed = z.object({ - sessionId: z.string().uuid(), - taskId: z.string().min(1), - approve: z.boolean(), - }).safeParse(req.body) - if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return } - - const rawToken = req.headers.authorization?.replace('Bearer ', '') ?? '' - try { - await handleApproveAction( - admin.firestore() as unknown as { doc(p: string): { update(d: Record): Promise } }, - req.uid!, - { ...parsed.data, idToken: rawToken }, - ) - res.json({ ok: true }) - } catch (err) { - console.error('approve-action error:', err) - res.status(500).json({ error: 'Internal server error' }) - } -}) -``` - -- [ ] **Step 5: Commit** - -```bash -git add cloud-agent/src/handlers/approveAction.ts cloud-agent/src/handlers/approveAction.test.ts \ - cloud-agent/src/index.ts -git commit -m "feat(bridge/p2): add POST /agent/browser/approve-action endpoint" -``` - ---- - -### Task 6: browserAction Tool — Teardown + Async Push on awaiting_auth - -**Decision (resolved):** When a task halts for approval, the voice/text turn does **not** block waiting for the (possibly multi-minute) approval. The tool narrates the pause, resumes billing, tears down the 30s wait, and ends the turn. The final result is delivered later via `sendTaskComplete` Expo Push (wired in Task 4 `onResult`, `isResume` branch). This avoids the 30s `textTimeoutMs` misfiring `EXECUTION_TIMEOUT` and resuming billing while the user is still deciding. - -`TaskDoc.status` already includes `awaiting_auth`, so `awaiting_auth` is treated as a terminal-for-this-turn status inside `waitForTerminalTask`. - -**Files:** -- Modify: `cloud-agent/src/tools/browserAction.ts` -- Modify: `cloud-agent/src/tools/browserAction.test.ts` - -- [ ] **Step 1: Write failing tests** - -Add to `cloud-agent/src/tools/browserAction.test.ts`: - -```typescript -test('voice path: awaiting_auth narrates pause, resumes billing, ends turn (no EXECUTION_TIMEOUT)', async () => { - const pushed: string[] = [] - let resumed = false - let taskWatcher: ((t: Record) => void) | null = null - let unsubbed = false - - const tool = browserActionTool({ - firebaseUid: 'fb-uid', - userId: 'user-id', - firestoreSession: { - getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'tok', deviceName: 'Mac' }), - createSession: async () => {}, - writeTask: async () => {}, - closeSession: async () => {}, - getTask: async () => ({ status: 'awaiting_auth', intent: {} as never }), - getSession: async () => ({ status: 'routing', browserInstanceId: 'i' }), - watchTask: (_u: string, _s: string, _t: string, cb) => { taskWatcher = cb as never; return () => { unsubbed = true } }, - } as never, - fcmDispatcher: { wakeExtension: async () => {} } as never, - creditService: { spendCredit: async () => 'tx1', refundCredit: async () => {} } as never, - instanceId: 'i-test', - pushToLive: (msg: string) => { pushed.push(msg) }, - pauseBilling: () => {}, - resumeBilling: () => { resumed = true }, - wakeTimeoutMs: 50, - textTimeoutMs: 200, - }, { trigger: 'voice', preBilled: false }) - - await tool.execute({ actionSummary: 'Submit form', intent: { action: { type: 'click', selector: '#s', tier: 'stateful' } } }) - - await new Promise((r) => setTimeout(r, 20)) - taskWatcher!({ status: 'awaiting_auth' }) - await new Promise((r) => setTimeout(r, 20)) - - assert.ok(pushed.some((m) => m.toLowerCase().includes('pause') || m.toLowerCase().includes('phone'))) - assert.ok(resumed, 'billing must resume at the pause') - assert.ok(unsubbed, 'watchTask listener must be torn down') - - // Past the 200ms cap: no EXECUTION_TIMEOUT message should appear. - await new Promise((r) => setTimeout(r, 250)) - assert.ok(!pushed.some((m) => m.toLowerCase().includes('timeout') || m.toLowerCase().includes('30s'))) -}) - -test('text path: awaiting_auth returns a phone-approval message', async () => { - let taskWatcher: ((t: Record) => void) | null = null - const tool = browserActionTool({ - firebaseUid: 'fb-uid', - userId: 'user-id', - firestoreSession: { - getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'tok', deviceName: 'Mac' }), - createSession: async () => {}, - writeTask: async () => {}, - closeSession: async () => {}, - getTask: async () => ({ status: 'awaiting_auth', intent: {} as never }), - getSession: async () => ({ status: 'routing', browserInstanceId: 'i' }), - watchTask: (_u: string, _s: string, _t: string, cb) => { taskWatcher = cb as never; return () => {} }, - } as never, - fcmDispatcher: { wakeExtension: async () => {} } as never, - creditService: { spendCredit: async () => 'tx1', refundCredit: async () => {} } as never, - instanceId: 'i-test', - wakeTimeoutMs: 50, - textTimeoutMs: 200, - }, { trigger: 'text', preBilled: true }) - - const p = tool.execute({ actionSummary: 'Submit form', intent: { action: { type: 'click', selector: '#s', tier: 'stateful' } } }) - await new Promise((r) => setTimeout(r, 20)) - taskWatcher!({ status: 'awaiting_auth' }) - const out = await p - assert.match(out, /phone|approve/i) -}) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd cloud-agent && node --test --import tsx/esm src/tools/browserAction.test.ts -``` - -Expected: FAIL — awaiting_auth currently is not a recognized terminal status; the 30s cap governs instead. - -- [ ] **Step 3: Patch `waitForTerminalTask` in `browserAction.ts`** - -Treat `awaiting_auth` as terminal-for-this-turn so the wait resolves immediately and the 30s timer never fires after a halt: - -```typescript -const unsub = fs.watchTask(deps.firebaseUid, sessionId, taskId, (task) => { - if ( - task.status === 'awaiting_auth' || - task.status === 'complete' || - task.status === 'failed' || - task.status === 'aborted' - ) { - settled = true - clearTimeout(timeout) - clearTimeout(wakeTimer) - unsub() - resolve(task) - } -}) -``` - -- [ ] **Step 4: Update `formatResult` to handle the halt status** - -```typescript -function formatResult(task: TaskDoc): string { - if (task.status === 'awaiting_auth') { - return "I've paused this action. Approve it on your phone and I'll finish — I'll let you know when it's done." - } - if (task.status === 'complete' && task.result) { - const data = task.result.data ?? {} - const body = Object.keys(data).length ? JSON.stringify(data) : '(no extracted data)' - return `Browser task complete on ${task.result.activeUrl}: ${body}` - } - const code = task.error?.code ?? task.result?.error?.code ?? 'EXECUTION_ERROR' - if (code === 'EXTENSION_OFFLINE') return 'Your browser extension appears to be offline.' - return `Browser task failed (${code}): ${task.error?.message ?? task.result?.error?.message ?? 'unknown error'}` -} -``` - -Both the text path (`return formatResult(await waitForTerminalTask())`) and the voice path (`.then((task) => { deps.resumeBilling?.(); deps.pushToLive?.(formatResult(task)) })`) reuse this — no further branching needed. The voice `.then` already calls `resumeBilling()` before narrating, satisfying the teardown-resumes-billing requirement for the halt case. - -- [ ] **Step 5: Run tests and verify pass** - -```bash -cd cloud-agent && node --test --import tsx/esm src/tools/browserAction.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add cloud-agent/src/tools/browserAction.ts cloud-agent/src/tools/browserAction.test.ts -git commit -m "feat(bridge/p2): teardown + async-push on awaiting_auth; no 30s misfire after halt" -``` - ---- - -### Task 7: Extension — Stateful Actions in Executor - -**Files:** -- Modify: `extension/src/content/executor.ts` -- Modify: `extension/src/content/executor.test.ts` - -- [ ] **Step 1: Write failing tests for `fill_field`, `click`, and classifier wiring** - -Add to `extension/src/content/executor.test.ts`: - -```typescript -import test from 'node:test' -import assert from 'node:assert/strict' -import { JSDOM } from 'jsdom' -import { runAction } from './executor.js' -import type { SingleAction } from '../shared/dsl-types.js' - -// JSDOM document — matches the pattern in dom-extractor.test.ts. -// runAction uses doc.querySelector + el.dispatchEvent(new Event(...)), -// all of which the JSDOM document provides natively. -function dom(html: string) { - const d = new JSDOM(html, { url: 'https://test.com' }) - return { doc: d.window.document, win: { scrollBy: () => {}, location: { href: 'https://test.com' } } } -} - -test('fill_field sets input value and fires input + change events', async () => { - const { doc, win } = dom('') - const events: string[] = [] - const el = doc.querySelector('#username')! - el.addEventListener('input', () => events.push('input')) - el.addEventListener('change', () => events.push('change')) - - const result = await runAction( - { type: 'fill_field', selector: '#username', value: 'hello', tier: 'stateful' } as SingleAction, - doc, win, { skipLayerTwo: true }, - ) - - assert.ok(!('awaitingAuth' in result)) - assert.equal((el as HTMLInputElement).value, 'hello') - assert.ok(events.includes('input')) - assert.ok(events.includes('change')) -}) - -test('click executes click on element', async () => { - const { doc, win } = dom('') - let clicked = false - doc.querySelector('#btn')!.addEventListener('click', () => { clicked = true }) - - await runAction( - { type: 'click', selector: '#btn', tier: 'stateful' } as SingleAction, - doc, win, { skipLayerTwo: true }, - ) - - assert.ok(clicked) -}) - -test('click returns awaitingAuth when classifier flags requires_auth (skipLayerTwo: false)', async () => { - const { doc, win } = dom('') // matches DESTRUCTIVE_ACTION_PATTERN - const result = await runAction( - { type: 'click', selector: '#pay', tier: 'stateful' } as SingleAction, - doc, win, { skipLayerTwo: false }, - ) - assert.ok('awaitingAuth' in result) -}) - -test('fill_field on form submit input returns awaitingAuth', async () => { - const { doc, win } = dom('
    ') - const result = await runAction( - { type: 'fill_field', selector: '#s', value: 'x', tier: 'stateful' } as SingleAction, - doc, win, { skipLayerTwo: false }, - ) - assert.ok('awaitingAuth' in result) -}) - -test('fill_field throws SELECTOR_NOT_FOUND when element missing', async () => { - const { doc, win } = dom('
    ') - await assert.rejects( - () => runAction({ type: 'fill_field', selector: '#missing', value: 'x', tier: 'stateful' } as SingleAction, doc, win, {}), - /SELECTOR_NOT_FOUND/, - ) -}) - -test('click throws SELECTOR_NOT_FOUND when element missing', async () => { - const { doc, win } = dom('
    ') - await assert.rejects( - () => runAction({ type: 'click', selector: '#missing', tier: 'stateful' } as SingleAction, doc, win, {}), - /SELECTOR_NOT_FOUND/, - ) -}) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd extension && node --test --import tsx/esm src/content/executor.test.ts -``` - -Expected: FAIL — `fill_field` and `click` throw "not enabled in Phase 1". - -- [ ] **Step 3: Implement stateful actions in `executor.ts`** - -Replace the file: - -```typescript -import type { SingleAction } from '../shared/dsl-types.js' -import { extract, readDom, summarizeVisibleText } from './dom-extractor.js' -import { classifyElement } from './safety-classifier.js' - -export interface ActionOutcome { data: Record; activeUrl: string } -export type ActionResult = ActionOutcome | { awaitingAuth: true } - -interface WinLike { scrollBy(x: number, y: number): void; location: { href: string } } - -export interface RunActionContext { - skipLayerTwo?: boolean // true on resume after explicit user approval -} - -export async function runAction( - action: SingleAction, - doc: Document, - win: WinLike, - ctx: RunActionContext = {}, -): Promise { - const activeUrl = win.location.href - switch (action.type) { - case 'extract': - return { data: extract(doc, action.selector, action.label ?? 'value'), activeUrl } - case 'read_dom': - return { data: { read_dom: readDom(doc, action.selector) }, activeUrl } - case 'summarize_visible_text': - return { data: { summary: summarizeVisibleText(doc, action.filter ?? 'all') }, activeUrl } - case 'scroll': { - const delta = (action.pixels ?? 600) * (action.direction === 'up' ? -1 : 1) - win.scrollBy(0, delta) - return { data: {}, activeUrl } - } - case 'open_tab': - case 'focus_tab': - throw new Error('EXECUTION_ERROR: tab actions are handled by the service worker') - case 'fill_field': { - const el = doc.querySelector(action.selector) - if (!el) throw new Error('SELECTOR_NOT_FOUND') - if (!ctx.skipLayerTwo && classifyElement(el) === 'requires_auth') return { awaitingAuth: true } - ;(el as HTMLInputElement).value = action.value - // Build events from the element's own view so this works under JSDOM (tests) - // and in the injected page context (runtime) alike. - const EventCtor = (el.ownerDocument?.defaultView ?? globalThis).Event - el.dispatchEvent(new EventCtor('input', { bubbles: true })) - el.dispatchEvent(new EventCtor('change', { bubbles: true })) - return { data: {}, activeUrl } - } - case 'click': { - const el = doc.querySelector(action.selector) - if (!el) throw new Error('SELECTOR_NOT_FOUND') - if (!ctx.skipLayerTwo && classifyElement(el) === 'requires_auth') return { awaitingAuth: true } - ;(el as HTMLElement).click() - return { data: {}, activeUrl } - } - default: - throw new Error('EXECUTION_ERROR: unknown action') - } -} - -export async function runActionInPage(action: SingleAction, ctx: RunActionContext = {}): Promise { - return runAction(action, document, window as unknown as WinLike, ctx) -} -``` - -- [ ] **Step 4: Run tests and verify pass** - -```bash -cd extension && node --test --import tsx/esm src/content/executor.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add extension/src/content/executor.ts extension/src/content/executor.test.ts -git commit -m "feat(bridge/p2): implement fill_field and click with Layer 2 safety classifier" -``` - ---- - -### Task 8: Extension — Task Dispatcher Halt + WsClient sendAwaitingAuth - -**Files:** -- Modify: `extension/src/background/task-dispatcher.ts` -- Modify: `extension/src/background/task-dispatcher.test.ts` -- Modify: `extension/src/background/ws-client.ts` -- Modify: `extension/src/background/ws-client.test.ts` -- Modify: `extension/src/background/content-bridge.ts` - -- [ ] **Step 1: Write failing tests** - -Add to `extension/src/background/task-dispatcher.test.ts`: - -```typescript -import { dispatchTask } from '../background/task-dispatcher.js' -import type { TaskIntent } from '../shared/dsl-types.js' - -const baseIntent: TaskIntent = { - version: '1', taskId: 't1', sessionId: 's1', - requiresAuth: true, actionSummary: 'Submit', - action: { type: 'sequence', steps: [ - { type: 'extract', selector: '.price', label: 'price' }, - { type: 'click', selector: '#buy', label: 'Buy', tier: 'stateful' }, - ] }, -} - -test('dispatchTask halts with awaiting_auth when step returns awaitingAuth', async () => { - let stepIdx = 0 - const inj = { - runInActiveTab: async () => { - if (stepIdx++ === 1) return { awaitingAuth: true as const } - return { data: { price: '$10' }, activeUrl: 'https://x.com' } - }, - openTab: async () => {}, - focusTab: async () => {}, - } as never - - const outcome = await dispatchTask(baseIntent, inj) - assert.equal(outcome.status, 'awaiting_auth') - assert.equal((outcome as { haltedStepIndex: number }).haltedStepIndex, 1) -}) - -test('dispatchTask completes when no step requires auth', async () => { - const inj = { - runInActiveTab: async () => ({ data: { price: '$10' }, activeUrl: 'https://x.com' }), - openTab: async () => {}, - focusTab: async () => {}, - } as never - - const intent: TaskIntent = { ...baseIntent, requiresAuth: false, action: { type: 'extract', selector: '.p', label: 'p' } } - const outcome = await dispatchTask(intent, inj) - assert.equal(outcome.status, 'complete') -}) -``` - -Add to `extension/src/background/ws-client.test.ts`: - -```typescript -test('sendAwaitingAuth sends correct frame', async () => { - const sent: string[] = [] - const { createWsClient } = await import('./ws-client.js') - const client = createWsClient({ - url: 'ws://test', - idToken: 'tok', sessionId: 's1', deviceId: 'd1', - onTask: () => {}, onSessionEnd: () => {}, - WebSocketImpl: class { - onopen: (() => void) | null = null - onmessage: ((e: { data: string }) => void) | null = null - onclose: (() => void) | null = null - readyState = 1 - send(s: string) { sent.push(s) } - close() {} - } as never, - }) - client.connect() - client.sendAwaitingAuth('t1', 2) - const frame = JSON.parse(sent.find((s) => JSON.parse(s).type === 'awaiting_auth') ?? '{}') as { type: string; taskId: string; haltedStepIndex: number } - assert.equal(frame.type, 'awaiting_auth') - assert.equal(frame.taskId, 't1') - assert.equal(frame.haltedStepIndex, 2) -}) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -cd extension && node --test --import tsx/esm src/background/task-dispatcher.test.ts src/background/ws-client.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 3: Update `task-dispatcher.ts`** - -Replace the file: - -```typescript -import type { TaskIntent, SingleAction, TaskResult, BridgeErrorCode } from '../shared/dsl-types.js' - -export interface Injector { - runInActiveTab(action: SingleAction, ctx?: { skipLayerTwo?: boolean }): Promise<{ data: Record; activeUrl: string } | { awaitingAuth: true }> - openTab(url: string): Promise - focusTab(host: string): Promise -} - -export type AwaitingAuthOutcome = { status: 'awaiting_auth'; taskId: string; haltedStepIndex: number } -export type DispatchOutcome = TaskResult | AwaitingAuthOutcome - -function knownCode(msg: string): BridgeErrorCode { - for (const c of ['SELECTOR_NOT_FOUND', 'HOST_NOT_ALLOWED', 'HOST_PERMISSION_REQUIRED', 'EXECUTION_TIMEOUT'] as const) { - if (msg.includes(c)) return c - } - return 'EXECUTION_ERROR' -} - -export async function dispatchTask(intent: TaskIntent, inj: Injector): Promise { - const steps: SingleAction[] = intent.action.type === 'sequence' ? intent.action.steps : [intent.action] - // On resume (requiresAuth: false from Cloud Agent sliced intent), first step is pre-approved - const skipLayerTwoForFirst = !intent.requiresAuth && steps.length > 0 && - (steps[0].type === 'fill_field' || steps[0].type === 'click') - - const data: Record = {} - let activeUrl = '' - try { - for (let i = 0; i < steps.length; i++) { - const step = steps[i] - if (step.type === 'open_tab') { await inj.openTab(step.url); activeUrl = step.url; continue } - if (step.type === 'focus_tab') { await inj.focusTab(step.host); continue } - const ctx = { skipLayerTwo: skipLayerTwoForFirst && i === 0 } - const out = await inj.runInActiveTab(step, ctx) - if ('awaitingAuth' in out) { - return { status: 'awaiting_auth', taskId: intent.taskId, haltedStepIndex: i } - } - Object.assign(data, out.data) - activeUrl = out.activeUrl || activeUrl - } - return { taskId: intent.taskId, status: 'complete', data, activeUrl } - } catch (err) { - const msg = err instanceof Error ? err.message : 'execution failed' - return { - taskId: intent.taskId, status: 'failed', data, activeUrl, - error: { code: knownCode(msg), message: msg, failedAction: steps[0] }, - } - } -} -``` - -- [ ] **Step 4: Update `content-bridge.ts` to propagate awaitingAuth from injected script** - -In `content-bridge.ts`, update `runInActiveTab`: - -```typescript -async runInActiveTab(action: SingleAction, ctx: { skipLayerTwo?: boolean } = {}) { - const tab = await activeTab() - if (tab.url) await ensureHost(tab.url) - const [res] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: runActionInPage as unknown as (...a: unknown[]) => unknown, - args: [action, ctx], - }) - const out = res?.result as { data: Record; activeUrl: string } | { awaitingAuth: true } | undefined - if (!out) throw new Error('EXECUTION_ERROR: empty injection result') - if ('awaitingAuth' in out) return { awaitingAuth: true as const } - return out -}, -``` - -- [ ] **Step 5: Add `sendAwaitingAuth` to `ws-client.ts`** - -In `ws-client.ts`, add to the returned object: - -```typescript -sendAwaitingAuth(taskId: string, haltedStepIndex: number): void { - sock?.send(JSON.stringify({ type: 'awaiting_auth', taskId, haltedStepIndex })) -}, -``` - -- [ ] **Step 6: Run tests and verify pass** - -```bash -cd extension && node --test --import tsx/esm \ - src/background/task-dispatcher.test.ts \ - src/background/ws-client.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add extension/src/background/task-dispatcher.ts extension/src/background/task-dispatcher.test.ts \ - extension/src/background/ws-client.ts extension/src/background/ws-client.test.ts \ - extension/src/background/content-bridge.ts -git commit -m "feat(bridge/p2): task-dispatcher halt on awaiting_auth; ws-client sendAwaitingAuth" -``` - ---- - -### Task 9: Extension — Service Worker Handles awaiting_auth Outcome - -**Files:** -- Modify: `extension/src/background/service-worker.ts` - -- [ ] **Step 1: Review current `onTask` handler** - -Open `extension/src/background/service-worker.ts`, find `onTask`: - -```typescript -onTask: (intent) => { - void (async () => { - const result = await dispatchTask(intent, injector) - await appendActionLog(intent, result.status) - client.sendResult(result) - })() -}, -``` - -- [ ] **Step 2: Update service-worker.ts to handle awaiting_auth outcome and resume** - -Replace the `chrome.gcm.onMessage.addListener` call and the `wakeAndConnect` function: - -```typescript -chrome.gcm.onMessage.addListener((message) => { - const data = message.data as { type?: string; sessionId?: string; taskId?: string; resume?: string } - if (data.type !== 'WAKE_AND_CONNECT' || !data.sessionId) return - void wakeAndConnect(data.sessionId) -}) - -async function wakeAndConnect(sessionId: string): Promise { - const { paused } = await chrome.storage.local.get('paused') - if (paused) return - await ensureOffscreen() - let idToken: string - try { idToken = await requestIdToken() } catch (e) { console.error('No auth for wake:', e); return } - const deviceId = await getDeviceId() - const injector = createInjector() - - const client = createWsClient({ - url: CLOUD_WS_URL, idToken, sessionId, deviceId, - onSessionReady: () => { - void chrome.storage.local.get('gcmToken').then(({ gcmToken }) => { - if (gcmToken) void upsertDeviceRegistration(idToken, gcmToken as string) - }) - }, - onTask: (intent) => { - void (async () => { - const outcome = await dispatchTask(intent, injector) - if (outcome.status === 'awaiting_auth') { - client.sendAwaitingAuth(outcome.taskId, outcome.haltedStepIndex) - await appendActionLog(intent, 'awaiting_auth') - // Close WS — service worker suspends; Cloud Agent will re-wake via FCM on approval - client.close() - void closeOffscreen() - return - } - const result = outcome as import('../shared/dsl-types.js').TaskResult - await appendActionLog(intent, result.status) - client.sendResult(result) - })() - }, - onSessionEnd: () => { client.close(); void closeOffscreen() }, - }) - client.connect() -} -``` - -- [ ] **Step 3: Build extension to check for TypeScript errors** - -```bash -cd extension && node esbuild.mjs -``` - -Expected: build succeeds with no type errors. - -- [ ] **Step 4: Commit** - -```bash -git add extension/src/background/service-worker.ts -git commit -m "feat(bridge/p2): service worker handles awaiting_auth dispatch outcome and suspends" -``` - ---- - -### Task 10: Mobile App — Push Token Registration - -**Files:** -- Create: `src/hooks/useRegisterExpoPushToken.ts` -- Modify: `app/_layout.tsx` - -- [ ] **Step 1: Install expo-notifications** - -```bash -npx expo install expo-notifications expo-task-manager -``` - -For bare workflow, run: - -```bash -cd ios && pod install && cd .. -``` - -- [ ] **Step 2: Write test for `useRegisterExpoPushToken`** - -Create `__tests__/useRegisterExpoPushToken.test.ts`: - -```typescript -import { renderHook } from '@testing-library/react-hooks' -import { useRegisterExpoPushToken } from '~/hooks/useRegisterExpoPushToken' - -jest.mock('expo-notifications', () => ({ - getPermissionsAsync: jest.fn().mockResolvedValue({ status: 'undetermined' }), - requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }), - getExpoPushTokenAsync: jest.fn().mockResolvedValue({ data: 'ExponentPushToken[test]' }), - setNotificationCategoryAsync: jest.fn().mockResolvedValue(true), -})) - -jest.mock('~/config/firebaseConfig', () => ({ - getCurrentUser: jest.fn().mockReturnValue({ getIdToken: jest.fn().mockResolvedValue('id-tok') }), -})) - -jest.mock('../shared/localCloudAgent', () => ({ - getCloudAgentBaseUrl: () => 'https://agent.test', -})) - -global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }) as jest.Mock - -describe('useRegisterExpoPushToken', () => { - it('registers token and POSTs to cloud agent', async () => { - const { result } = renderHook(() => useRegisterExpoPushToken({ enabled: true, projectId: 'test-proj' })) - await new Promise((r) => setTimeout(r, 50)) - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/agent/user/expo-push-token'), - expect.objectContaining({ method: 'POST' }), - ) - }) -}) -``` - -- [ ] **Step 3: Run test to confirm it fails** - -```bash -npx jest __tests__/useRegisterExpoPushToken.test.ts -``` - -Expected: FAIL — module not found. - -- [ ] **Step 4: Create `src/hooks/useRegisterExpoPushToken.ts`** - -```typescript -import { useEffect } from 'react' -import * as Notifications from 'expo-notifications' -import { getCloudAgentBaseUrl } from '../../shared/localCloudAgent' -import { getCurrentUser } from '~/config/firebaseConfig' - -interface Options { - enabled: boolean - projectId: string -} - -export function useRegisterExpoPushToken({ enabled, projectId }: Options): void { - useEffect(() => { - if (!enabled) return - void (async () => { - const { status: existing } = await Notifications.getPermissionsAsync() - const { status } = existing === 'granted' - ? { status: 'granted' as const } - : await Notifications.requestPermissionsAsync() - if (status !== 'granted') return - - const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({ projectId }) - const user = getCurrentUser() - if (!user) return - const idToken = await user.getIdToken() - - await fetch(`${getCloudAgentBaseUrl()}/agent/user/expo-push-token`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: `Bearer ${idToken}` }, - body: JSON.stringify({ expoPushToken }), - }) - })() - }, [enabled, projectId]) -} -``` - -- [ ] **Step 5: Run test and verify pass** - -```bash -npx jest __tests__/useRegisterExpoPushToken.test.ts -``` - -Expected: PASS. - -- [ ] **Step 6: Wire hook into `app/_layout.tsx`** - -Add near other `useEffect` hooks in the app root (in `AppOrchestrator` or the root layout): - -```typescript -import { useRegisterExpoPushToken } from '~/hooks/useRegisterExpoPushToken' -import Constants from 'expo-constants' - -// Inside AppOrchestrator or root layout component: -const isSignedIn = useSelector(authService, (state) => state.matches('signedIn')) -useRegisterExpoPushToken({ - enabled: isSignedIn, - projectId: Constants.expoConfig?.extra?.eas?.projectId ?? '', -}) -``` - -- [ ] **Step 7: Commit** - -```bash -git add src/hooks/useRegisterExpoPushToken.ts __tests__/useRegisterExpoPushToken.test.ts app/_layout.tsx -git commit -m "feat(bridge/p2): register Expo push token on sign-in" -``` - ---- - -### Task 11: Mobile App — Notification Category + Background Approval Handler - -**Files:** -- Create: `src/hooks/useBrowserActionApproval.ts` -- Modify: `app/_layout.tsx` - -- [ ] **Step 1: Write test for background task definition and category registration** - -Create `__tests__/useBrowserActionApproval.test.ts`: - -```typescript -import { renderHook } from '@testing-library/react-hooks' - -jest.mock('expo-notifications', () => ({ - setNotificationCategoryAsync: jest.fn().mockResolvedValue(true), -})) - -jest.mock('expo-task-manager', () => ({ - defineTask: jest.fn(), - isTaskDefined: jest.fn().mockReturnValue(false), -})) - -jest.mock('@react-native-firebase/auth', () => ({ - getAuth: jest.fn().mockReturnValue({ currentUser: { getIdToken: jest.fn().mockResolvedValue('id-tok') } }), -})) - -global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }) as jest.Mock - -import * as TaskManager from 'expo-task-manager' -import { setupBrowserActionApproval } from '~/hooks/useBrowserActionApproval' - -describe('setupBrowserActionApproval', () => { - it('registers BROWSER_ACTION_APPROVAL notification category', async () => { - const Notifications = require('expo-notifications') as { setNotificationCategoryAsync: jest.Mock } - await setupBrowserActionApproval() - expect(Notifications.setNotificationCategoryAsync).toHaveBeenCalledWith( - 'BROWSER_ACTION_APPROVAL', - expect.arrayContaining([ - expect.objectContaining({ identifier: 'APPROVE' }), - expect.objectContaining({ identifier: 'DENY' }), - ]), - ) - expect(TaskManager.defineTask).toHaveBeenCalledWith( - 'BROWSER_ACTION_APPROVAL_RESPONSE', - expect.any(Function), - ) - }) -}) -``` - -- [ ] **Step 2: Run test to confirm it fails** - -```bash -npx jest __tests__/useBrowserActionApproval.test.ts -``` - -Expected: FAIL. - -- [ ] **Step 3: Create `src/hooks/useBrowserActionApproval.ts`** - -```typescript -import { useEffect } from 'react' -import * as Notifications from 'expo-notifications' -import * as TaskManager from 'expo-task-manager' -import { getAuth } from '@react-native-firebase/auth' -import { getCloudAgentBaseUrl } from '../../shared/localCloudAgent' - -export const APPROVAL_TASK = 'BROWSER_ACTION_APPROVAL_RESPONSE' - -export async function setupBrowserActionApproval(): Promise { - // Register notification category with lock-screen APPROVE / DENY buttons - await Notifications.setNotificationCategoryAsync('BROWSER_ACTION_APPROVAL', [ - { - identifier: 'APPROVE', - buttonTitle: 'Approve', - options: { opensAppToForeground: false }, - }, - { - identifier: 'DENY', - buttonTitle: 'Deny', - options: { opensAppToForeground: false }, - }, - ]) - - // Define background task (safe to call multiple times — TaskManager deduplicates) - if (!TaskManager.isTaskDefined(APPROVAL_TASK)) { - TaskManager.defineTask(APPROVAL_TASK, async ({ data }: { data: { notification: Notifications.Notification; actionIdentifier: string } }) => { - try { - const { notification, actionIdentifier } = data - const { sessionId, taskId } = notification.request.content.data as { sessionId: string; taskId: string } - const user = getAuth().currentUser - if (!user) return TaskManager.TaskExecutionResult.SUCCESS - - const idToken = await user.getIdToken() - await fetch(`${getCloudAgentBaseUrl()}/agent/browser/approve-action`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${idToken}`, - }, - body: JSON.stringify({ - sessionId, - taskId, - approve: actionIdentifier === 'APPROVE', - }), - }) - } catch (err) { - console.error('Browser action approval handler error:', err) - } - return TaskManager.TaskExecutionResult.SUCCESS - }) - } - - // Register task to fire on notification action responses - await Notifications.registerTaskAsync(APPROVAL_TASK) -} - -export function useBrowserActionApproval(): void { - useEffect(() => { - void setupBrowserActionApproval() - }, []) -} -``` - -- [ ] **Step 4: Run test and verify pass** - -```bash -npx jest __tests__/useBrowserActionApproval.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Wire hook into `app/_layout.tsx`** - -```typescript -import { useBrowserActionApproval } from '~/hooks/useBrowserActionApproval' - -// Inside root layout component (runs once on app startup): -useBrowserActionApproval() -``` - -- [ ] **Step 6: Commit** - -```bash -git add src/hooks/useBrowserActionApproval.ts __tests__/useBrowserActionApproval.test.ts app/_layout.tsx -git commit -m "feat(bridge/p2): notification category BROWSER_ACTION_APPROVAL + background handler" -``` - ---- - -### Task 12: Firestore Security Rules Update - -**Files:** -- Modify: `firestore.rules` (path in repo root or `firebase/firestore.rules`) - -- [ ] **Step 1: Locate the rules file** - -```bash -find . -name "firestore.rules" | grep -v node_modules -``` - -- [ ] **Step 2: Update the auth doc rules** - -Find the `match /users/{uid}/sessions/{sessionId}/auth/{taskId}` block and replace with: - -```firestore -match /users/{uid}/sessions/{sessionId}/auth/{taskId} { - allow read: if request.auth.uid == uid; - // Admin SDK creates the auth doc (haltForAuth). Mobile writes approval decision only. - allow update: if request.auth.uid == uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['status', 'approvalToken', 'approvedAt']); -} -``` - -If the auth doc block doesn't exist yet, add it inside `match /users/{uid} {`. - -- [ ] **Step 3: Deploy rules to staging** - -```bash -firebase deploy --only firestore:rules --project staging -``` - -- [ ] **Step 4: Commit** - -```bash -git add firestore.rules -git commit -m "feat(bridge/p2): Firestore rules — allow mobile to write auth doc approval fields" -``` - ---- - -### Task 13: Phase Gate — E2E Validation on Staging Payment Form - -**Manual test. No code changes.** - -- [ ] **Step 1: Build and load the extension unpacked** - -```bash -cd extension && node esbuild.mjs -``` - -Load `extension/dist` as unpacked extension in Chrome. Sign in via side panel. - -- [ ] **Step 2: Prepare the staging payment form** - -Open a staging form with a payment submit button (e.g., Stripe test checkout). Confirm the form has a `#submit-order-btn` or equivalent selector. - -- [ ] **Step 3: Send a mixed-tier task via voice** - -In the mobile app, say: -> "Fill in the shipping address and submit the order on the checkout page." - -Expected: -1. Extension opens; extracts form fields; fills shipping address. -2. Extension halts at submit button — Layer 2 classifier triggers. -3. Mobile receives Expo Push notification: "Clanker needs your approval — Submit payment of $XX." -4. Voice says: "I've paused the browser action. Check your phone to approve." - -- [ ] **Step 4: Tap Approve on the mobile notification** - -Expected: -1. Mobile sends `POST /agent/browser/approve-action { approve: true }`. -2. Cloud Agent watchAuth fires, verifies token, sends FCM WAKE_AND_CONNECT resume. -3. Extension reconnects, receives sliced intent (starting at submit step with `requiresAuth: false`). -4. Extension clicks the submit button (Layer 2 skipped for first step on resume). -5. Extension sends `task_result` with completion. -6. Voice narrates the result. - -- [ ] **Step 5: Test denial flow** - -Repeat and tap Deny instead. - -Expected: -1. Voice says "Action was denied." or "Browser task failed (AUTH_TIMEOUT)." -2. Extension receives `session_end` and suspends. - -- [ ] **Step 6: Phase gate passed — tag** - -```bash -git tag phase2-gate-passed -``` - ---- - -## Self-Review Against Spec - -### Spec coverage check: - -| Requirement | Task | -|-------------|------| -| `fill_field` + `click` execution | Task 7 | -| Layer 2 safety classifier wired | Task 7 | -| `haltedStepIndex` write on halt | Task 1, 4 | -| Expo Push approval card | Task 2, 4 | -| Auth doc lifecycle (pending→approved/denied) | Task 1, 5 | -| Mobile APPROVE/DENY lock-screen buttons | Task 11 | -| `approvalToken` verification | Task 4 | -| FCM WAKE_AND_CONNECT resume | Task 4 | -| Extension resumes from `haltedStepIndex` | Task 4 (Cloud Agent slices), Task 8 (dispatcher skip) | -| `requiresAuth: false` on sliced resume intent | Task 4 | -| Layer 2 skip on approved first step | Task 8 (skipLayerTwoForFirst) | -| Session → `pending_auth` on halt | Task 1 | -| Task → `awaiting_auth` on halt | Task 1, 8, 9 | -| Expo push token storage + registration | Task 3, 10 | -| `sendTaskComplete` async result push after approved task completes | Task 2 (impl), Task 4 (`onResult` `isResume` branch) | -| Auth TTL 5 min on auth doc | Task 1 (expiresAt written) | -| Voice/text behavior on awaiting_auth (teardown + async push, no 30s misfire) | Task 6 | -| WS close after sending awaiting_auth | Task 9 | -| Phase gate E2E test | Task 13 | - -### Placeholder scan: none found. - -### Type consistency: -- `DispatchOutcome` defined in Task 8 and used in Task 9 — consistent. -- `AwaitingAuthOutcome.haltedStepIndex` matches `awaitingAuthFrameSchema` field — consistent. -- `sendAwaitingAuth(taskId, haltedStepIndex)` in Task 8 matches Cloud Agent parse in Task 4 — consistent. -- `skipLayerTwo` context flows from `content-bridge.ts` → `executor.ts` → `classifyElement` — consistent. -- `AuthDoc.approvalToken` used as the verification token in Task 4 — consistent with Task 1 schema. diff --git a/docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md b/docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md deleted file mode 100644 index fe94e2e3..00000000 --- a/docs/superpowers/plans/2026-06-29-phase3-proactive-scheduler.md +++ /dev/null @@ -1,1014 +0,0 @@ -# Phase 3: Proactive Browser Bridge — Cloud Scheduler Triggers & Expo Push Async Completion - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Cloud Scheduler–triggered proactive browser tasks that fire without a user session, and Expo Push fallback delivery when the voice session closes before a browser task result arrives. - -**Architecture:** A new `POST /agent/browser/scheduler-trigger` endpoint accepts Bearer `SCHEDULER_SECRET` auth from Cloud Scheduler, creates a bridge session, wakes the extension via FCM, synchronously waits up to 60 s for the result, then sends an Expo Push notification via `sendProactive`. Separately, `pushToLive` in `wsLiveAgentHandler` gains a fallback path: when the Gemini session has already closed, the result is delivered via `sendTaskComplete` Expo Push instead of being silently dropped. - -**Tech Stack:** Node.js 22, TypeScript, Express, Firebase Admin SDK, Expo Push REST API, `node:test` + `node:assert/strict`, `supertest` - ---- - -## File Map - -| Action | File | Responsibility | -|--------|------|----------------| -| Modify | `cloud-agent/src/services/fcmDispatcher.ts` | Add `sendProactive` method | -| Modify | `cloud-agent/src/services/fcmDispatcher.test.ts` | Test `sendProactive` payload | -| Modify | `cloud-agent/src/tools/browserAction.ts` | Pass `sessionId` to `pushToLive` (signature change) | -| Modify | `cloud-agent/src/tools/browserAction.test.ts` | Update `pushToLive` mock signature | -| Modify | `cloud-agent/src/handlers/wsLiveAgentHandler.ts` | Add `getExpoPushToken` option; Expo Push fallback in `pushToLive` | -| Modify | `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` | Test Expo Push fallback when Gemini session closed | -| Create | `cloud-agent/src/handlers/schedulerTriggerHandler.ts` | HTTP handler for Cloud Scheduler trigger endpoint | -| Create | `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts` | Unit + HTTP tests for scheduler handler | -| Modify | `cloud-agent/src/index.ts` | Register `POST /agent/browser/scheduler-trigger` | -| Modify | `cloud-agent/src/index.test.ts` | Test route 401 on bad secret, 503 when `SCHEDULER_SECRET` unset | - ---- - -## Task 1: Add `sendProactive` to `fcmDispatcher.ts` - -**Files:** -- Modify: `cloud-agent/src/services/fcmDispatcher.test.ts` -- Modify: `cloud-agent/src/services/fcmDispatcher.ts` - -- [ ] **Step 1.1: Write failing test** - -Append to `cloud-agent/src/services/fcmDispatcher.test.ts`: - -```typescript -test('sendProactive POSTs PROACTIVE_TASK Expo Push payload', async () => { - const fetched: Array<{ body: unknown }> = [] - const fakeFetch = async (_url: string, opts: RequestInit) => { - fetched.push({ body: JSON.parse(opts.body as string) }) - return { ok: true, json: async () => ({ data: [{ status: 'ok' }] }) } - } - - const dispatcher = createFcmDispatcher( - { send: async () => 'msg-id' }, - fakeFetch as unknown as typeof fetch, - ) - - await dispatcher.sendProactive('ExponentPushToken[abc]', 'sid1', 'tid1', 'Price dropped to $340.') - - assert.equal(fetched.length, 1) - const body = fetched[0].body as Record - assert.equal(body.to, 'ExponentPushToken[abc]') - assert.equal(body.title, 'Clanker noticed something') - assert.equal(body.body, 'Price dropped to $340.') - assert.equal(body.categoryIdentifier, 'BROWSER_ACTION_APPROVAL') - assert.equal(body.priority, 'high') - const data = body.data as Record - assert.equal(data.type, 'PROACTIVE_TASK') - assert.equal(data.sessionId, 'sid1') - assert.equal(data.taskId, 'tid1') - assert.equal(data.deepLink, '/talk') -}) -``` - -- [ ] **Step 1.2: Run test to confirm it fails** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'sendProactive|FAIL|not a function|TypeError' -``` - -Expected: `TypeError: dispatcher.sendProactive is not a function` or similar. - -- [ ] **Step 1.3: Implement `sendProactive` in fcmDispatcher** - -In `cloud-agent/src/services/fcmDispatcher.ts`, add `sendProactive` inside the returned object (after `sendTaskComplete`): - -```typescript -async sendProactive(expoPushToken: string, sessionId: string, taskId: string, body: string): Promise { - await expoPush({ - to: expoPushToken, - title: 'Clanker noticed something', - body, - data: { type: 'PROACTIVE_TASK', sessionId, taskId, deepLink: '/talk' }, - categoryIdentifier: 'BROWSER_ACTION_APPROVAL', - priority: 'high', - }) -}, -``` - -- [ ] **Step 1.4: Run tests to confirm they pass** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'sendProactive|pass|fail' | head -10 -``` - -Expected: `sendProactive POSTs PROACTIVE_TASK Expo Push payload` → pass. - -- [ ] **Step 1.5: Commit** - -```bash -git add cloud-agent/src/services/fcmDispatcher.ts cloud-agent/src/services/fcmDispatcher.test.ts -git commit -m "feat(bridge): add sendProactive to fcmDispatcher for phase 3 scheduler notifications" -``` - ---- - -## Task 2: Extend `pushToLive` Signature - -The voice-side `pushToLive` callback currently takes `(taskId, text)`. To support the Expo Push fallback in Task 3, it needs `sessionId` too so `sendTaskComplete` can correlate the push payload. - -**Files:** -- Modify: `cloud-agent/src/tools/browserAction.ts` (call site only) -- Modify: `cloud-agent/src/tools/browserAction.test.ts` (mock signature only) - -- [ ] **Step 2.1: Update `BrowserActionDeps.pushToLive` signature in `browserAction.ts`** - -In `cloud-agent/src/tools/browserAction.ts`, change line: - -```typescript - // Voice-only: push the final result into the live Gemini session. - pushToLive?: (taskId: string, text: string) => void -``` - -to: - -```typescript - // Voice-only: push the final result into the live Gemini session. - pushToLive?: (taskId: string, sessionId: string, text: string) => void -``` - -- [ ] **Step 2.2: Update the `pushToLive` call in the voice path** - -In the same file, find: - -```typescript - void waitForTerminalTask().then((task) => { - deps.resumeBilling?.() - deps.pushToLive?.(taskId, formatResult(task)) - }) -``` - -Change to: - -```typescript - void waitForTerminalTask().then((task) => { - deps.resumeBilling?.() - deps.pushToLive?.(taskId, sessionId, formatResult(task)) - }) -``` - -- [ ] **Step 2.3: Update `pushToLive` mock in `browserAction.test.ts`** - -Search `browserAction.test.ts` for any test that passes a `pushToLive` mock. Update each mock from `(taskId, text)` to `(taskId, sessionId, text)`. Example: - -```typescript -// Before -pushToLive: (taskId: string, text: string) => { pushResults.push({ taskId, text }) }, - -// After -pushToLive: (taskId: string, _sessionId: string, text: string) => { pushResults.push({ taskId, text }) }, -``` - -If no existing tests use `pushToLive`, no change needed here — the interface change is sufficient. - -- [ ] **Step 2.4: Run tests to confirm no regressions** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'FAIL|pass|fail|Error' | tail -20 -``` - -Expected: all tests pass. - -- [ ] **Step 2.5: Commit** - -```bash -git add cloud-agent/src/tools/browserAction.ts cloud-agent/src/tools/browserAction.test.ts -git commit -m "feat(bridge): add sessionId param to pushToLive for Expo Push fallback path" -``` - ---- - -## Task 3: Expo Push Fallback in `wsLiveAgentHandler` - -When the Gemini/voice session closes before a browser task result arrives, the `pushToLive` callback should deliver the result via `sendTaskComplete` Expo Push instead of silently dropping it. - -**Files:** -- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.ts` -- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` - -- [ ] **Step 3.1: Write failing test** - -Append to `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts`. Find the end of existing tests and add: - -```typescript -test('pushToLive falls back to Expo Push when voice WS is closed', { timeout: 5000 }, async () => { - const db = makeMockDb([[mockUser], [mockCharacter]]) - - const expoPushCalls: Array<{ token: string; sessionId: string; taskId: string; text: string }> = [] - const mockFcmDispatcher = { - wakeExtension: async () => {}, - sendApprovalCard: async () => {}, - sendTaskComplete: async (token: string, sessionId: string, taskId: string, text: string) => { - expoPushCalls.push({ token, sessionId, taskId, text }) - }, - sendProactive: async () => {}, - } - - let watchTaskCallback: ((task: unknown) => void) | null = null - const mockFirestoreSession = { - getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'fcm-tok', deviceName: 'Mac' }), - createSession: async () => {}, - writeTask: async () => {}, - closeSession: async () => {}, - writeTaskResult: async () => {}, - getTask: async () => ({ status: 'pending' }), - getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }), - abortPendingTaskIfOffline: async () => false, - watchTask: (_uid: string, _sid: string, _tid: string, cb: (task: unknown) => void) => { - watchTaskCallback = cb - return () => {} - }, - } - - const mock = makeMockLiveConnect() - const { server, close } = createLiveTestServer({ - db, - creditService: mockCreditService, - verifyToken: async () => ({ uid: 'fb-uid-1' }), - liveConnect: mock.connect, - getExpoPushToken: async () => 'ExponentPushToken[test]', - browserBridge: { - firebaseUid: 'fb-uid-1', - userId: 'user-uuid-1', - firestoreSession: mockFirestoreSession as never, - fcmDispatcher: mockFcmDispatcher as never, - creditService: mockCreditService, - instanceId: 'inst-1', - wakeTimeoutMs: 50, - textTimeoutMs: 500, - }, - }) - const port = await listen(server) - - await new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`) - const timeout = setTimeout(() => reject(new Error('test timeout')), 4500) - - ws.on('open', () => { - ws.send(JSON.stringify({ type: 'auth', token: 'valid', characterId: CHAR_UUID })) - }) - - ws.on('message', (raw) => { - const msg = JSON.parse(raw.toString()) as { type: string } - if (msg.type !== 'session_ready') return - - // Simulate Gemini invoking browser_action - mock.triggerMessage({ - toolCall: { - functionCalls: [{ id: 'call-1', name: 'browser_action', args: { - actionSummary: 'Extract price', - intent: { action: { type: 'extract', selector: '.price', label: 'price' } }, - }}], - }, - }) - - // Close the WS before the task result arrives - setTimeout(() => ws.close(), 20) - }) - - ws.on('close', async () => { - // Task result arrives after WS is closed - await new Promise((r) => setTimeout(r, 100)) - watchTaskCallback?.({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://example.com' }, error: null }) - - await new Promise((r) => setTimeout(r, 100)) - - clearTimeout(timeout) - try { - assert.equal(expoPushCalls.length, 1, 'sendTaskComplete should be called once') - assert.equal(expoPushCalls[0].token, 'ExponentPushToken[test]') - assert.match(expoPushCalls[0].text, /\$340|complete/i) - resolve() - } catch (e) { - reject(e) - } - }) - - ws.on('error', reject) - }) - - await close() -}) -``` - -- [ ] **Step 3.2: Run to confirm test fails** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'pushToLive falls back|FAIL|AssertionError' | head -5 -``` - -Expected: `AssertionError [ERR_ASSERTION]: 0 == 1` — `sendTaskComplete` not called yet. - -- [ ] **Step 3.3: Add `getExpoPushToken` to `WsLiveHandlerOptions`** - -In `cloud-agent/src/handlers/wsLiveAgentHandler.ts`, add to the `WsLiveHandlerOptions` interface (after `browserBridge`): - -```typescript - /** Injectable for testing; defaults to DB lookup. */ - getExpoPushToken?: (firebaseUid: string) => Promise -``` - -- [ ] **Step 3.4: Import `getExpoPushToken` in the handler** - -At the top of `cloud-agent/src/handlers/wsLiveAgentHandler.ts`, add: - -```typescript -import { getExpoPushToken as dbGetExpoPushToken } from './expoPushToken.js' -``` - -- [ ] **Step 3.5: Add Expo Push fallback in `pushToLive`** - -In `wsLiveAgentHandler.ts`, find the `pushToLive` callback definition (inside `handleAuthMessage`): - -```typescript - pushToLive: (taskId: string, text: string) => { - const callId = browserCallByTaskId.get(taskId) - if (!callId) return - browserCallByTaskId.delete(taskId) - try { - geminiSession?.sendToolResponse({ - functionResponses: [{ id: callId, name: 'browser_action', response: { output: text } }], - }) - } catch { /* ignore */ } - }, -``` - -Replace with: - -```typescript - pushToLive: (taskId: string, bridgeSessionId: string, text: string) => { - const callId = browserCallByTaskId.get(taskId) - if (!callId) return - browserCallByTaskId.delete(taskId) - const sessionOpen = geminiSession !== null && ws.readyState === WebSocket.OPEN - if (sessionOpen) { - try { - geminiSession!.sendToolResponse({ - functionResponses: [{ id: callId, name: 'browser_action', response: { output: text } }], - }) - } catch { /* ignore */ } - return - } - // Voice session already closed — deliver result via Expo Push fallback. - if (bridgeBase?.fcmDispatcher && bridgeBase.firebaseUid) { - const fwd = bridgeBase.fcmDispatcher - const fbUid = bridgeBase.firebaseUid - const getToken = options.getExpoPushToken ?? ((uid: string) => dbGetExpoPushToken(options.db, uid)) - void getToken(fbUid).then((token) => { - if (token) return fwd.sendTaskComplete(token, bridgeSessionId, taskId, text) - }).catch((err) => console.error('[pushToLive Expo fallback]', err)) - } - }, -``` - -- [ ] **Step 3.6: Run tests to confirm passing** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'pushToLive falls back|pass|FAIL' | head -10 -``` - -Expected: `pushToLive falls back to Expo Push when voice WS is closed` → pass, no FAIL lines. - -- [ ] **Step 3.7: Commit** - -```bash -git add cloud-agent/src/handlers/wsLiveAgentHandler.ts cloud-agent/src/handlers/wsLiveAgentHandler.test.ts -git commit -m "feat(bridge): send Expo Push when voice session closes before browser task result" -``` - ---- - -## Task 4: Create `schedulerTriggerHandler.ts` - -**Files:** -- Create: `cloud-agent/src/handlers/schedulerTriggerHandler.ts` -- Create: `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts` - -- [ ] **Step 4.1: Write failing test** - -Create `cloud-agent/src/handlers/schedulerTriggerHandler.test.ts`: - -```typescript -// cloud-agent/src/handlers/schedulerTriggerHandler.test.ts -import assert from 'node:assert/strict' -import test from 'node:test' -import express from 'express' -import request from 'supertest' -import { createSchedulerTriggerHandler } from './schedulerTriggerHandler.js' -import type { TaskDoc } from '../../../shared/dsl-types.js' - -const SECRET = 'test-scheduler-secret-abc' - -function buildApp(overrides: { - getActiveDevice?: () => Promise - watchTask?: (uid: string, sid: string, tid: string, cb: (t: TaskDoc) => void) => () => void - sendProactive?: (token: string, sid: string, tid: string, body: string) => Promise - getExpoPushToken?: (uid: string) => Promise -} = {}) { - const mockFs = { - getActiveDevice: overrides.getActiveDevice ?? (async () => ({ deviceId: 'd1', fcmToken: 'fcm-tok', deviceName: 'Mac' })), - createSession: async () => {}, - writeTask: async () => {}, - closeSession: async () => {}, - abortPendingTaskIfOffline: async () => false, - watchTask: overrides.watchTask ?? ((_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => { - // Resolve immediately with complete - setTimeout(() => cb({ status: 'complete', result: { data: { price: '$340' }, activeUrl: 'https://x.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5) - return () => {} - }), - } - - const proactiveCalls: Array<{ token: string; sid: string; tid: string; body: string }> = [] - const mockFcm = { - wakeExtension: async () => {}, - sendProactive: overrides.sendProactive ?? (async (token: string, sid: string, tid: string, body: string) => { - proactiveCalls.push({ token, sid, tid, body }) - }), - } - - const handler = createSchedulerTriggerHandler( - mockFs as never, - mockFcm as never, - overrides.getExpoPushToken ?? (async () => 'ExponentPushToken[sched]'), - mockCredit as never, - overrides.resolveUserId ?? (async () => 'user-db-id'), - { secret: SECRET, schedulerTimeoutMs: 200 }, - ) - - const app = express() - app.use(express.json()) - app.post('/agent/browser/scheduler-trigger', handler) - - return { app, proactiveCalls } -} - -test('returns 401 with no Authorization header', async () => { - const { app } = buildApp() - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'price' }, actionSummary: 'Extract', notificationBody: 'Done' }) - assert.equal(res.status, 401) -}) - -test('returns 401 with wrong secret', async () => { - const { app } = buildApp() - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', 'Bearer wrong-secret') - .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'price' }, actionSummary: 'Extract', notificationBody: 'Done' }) - assert.equal(res.status, 401) -}) - -test('returns 422 when no active device', async () => { - const { app } = buildApp({ getActiveDevice: async () => null }) - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', `Bearer ${SECRET}`) - .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'price' }, actionSummary: 'Extract', notificationBody: 'Done' }) - assert.equal(res.status, 422) - assert.match(res.body.error, /no active device/i) -}) - -test('returns 422 for blocked host', async () => { - const { app } = buildApp() - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', `Bearer ${SECRET}`) - .send({ uid: 'u1', action: { type: 'open_tab', url: 'chrome://settings' }, actionSummary: 'Open', notificationBody: 'Done' }) - assert.equal(res.status, 422) - assert.match(res.body.error, /HOST_NOT_ALLOWED/i) -}) - -test('returns 200 and sends Expo Push on successful task', async () => { - const { app, proactiveCalls } = buildApp() - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', `Bearer ${SECRET}`) - .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' }) - assert.equal(res.status, 200) - assert.equal(res.body.ok, true) - assert.ok(res.body.sessionId) - assert.ok(res.body.taskId) - assert.equal(proactiveCalls.length, 1) - assert.equal(proactiveCalls[0].token, 'ExponentPushToken[sched]') - assert.equal(proactiveCalls[0].body, 'Price check done.') -}) - -test('returns 200 with failure body when task fails', async () => { - const { app, proactiveCalls } = buildApp({ - watchTask: (_u: string, _s: string, _t: string, cb: (t: TaskDoc) => void) => { - setTimeout(() => cb({ status: 'failed', result: null, error: { code: 'SELECTOR_NOT_FOUND', message: 'not found', failedAction: { type: 'extract', selector: '.p' } as never }, intent: { action: { type: 'extract', selector: '.p' } } } as unknown as TaskDoc), 5) - return () => {} - }, - }) - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', `Bearer ${SECRET}`) - .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' }) - assert.equal(res.status, 200) - assert.equal(res.body.status, 'failed') - assert.equal(proactiveCalls.length, 1) - assert.match(proactiveCalls[0].body, /SELECTOR_NOT_FOUND|failed/i) -}) - -test('returns 504 and no Expo Push on timeout', async () => { - const { app, proactiveCalls } = buildApp({ - watchTask: () => () => {}, // never resolves - }) - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', `Bearer ${SECRET}`) - .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' }) - assert.equal(res.status, 504) - assert.equal(proactiveCalls.length, 0) -}) - -test('returns 200 without Expo Push when no token registered', async () => { - const { app, proactiveCalls } = buildApp({ - getExpoPushToken: async () => null, - }) - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', `Bearer ${SECRET}`) - .send({ uid: 'u1', action: { type: 'extract', selector: '.price', label: 'price' }, actionSummary: 'Extract price', notificationBody: 'Price check done.' }) - assert.equal(res.status, 200) - assert.equal(proactiveCalls.length, 0) -}) -``` - -- [ ] **Step 4.2: Run to confirm all tests fail (module not found)** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'schedulerTrigger|Cannot find|FAIL' | head -5 -``` - -Expected: `Cannot find module './schedulerTriggerHandler.js'` or compilation error. - -- [ ] **Step 4.3: Implement `schedulerTriggerHandler.ts`** - -Create `cloud-agent/src/handlers/schedulerTriggerHandler.ts`: - -```typescript -import { z } from 'zod' -import type { Request, Response } from 'express' -import type { FirestoreSession } from '../services/firestoreSession.js' -import type { FcmDispatcher } from '../services/fcmDispatcher.js' -import type { TaskDoc, SingleAction, SequenceAction } from '../../../shared/dsl-types.js' -import { intentRequiresAuth } from '../../../shared/constants.js' -import { findBlockedNavigation } from '../../../shared/hostPolicy.js' -import { INSTANCE_ID } from '../services/instanceId.js' - -export interface SchedulerTriggerOptions { - /** SCHEDULER_SECRET env var value — requests must Bearer-match this */ - secret: string - schedulerTimeoutMs?: number -} - -const schedulerBodySchema = z.object({ - uid: z.string().min(1), - action: z.record(z.string(), z.unknown()), - actionSummary: z.string().min(1), - notificationBody: z.string().min(1), -}) - -function watchTaskPromise( - fs: FirestoreSession, - uid: string, - sessionId: string, - taskId: string, -): Promise { - return new Promise((resolve) => { - const unsub = fs.watchTask(uid, sessionId, taskId, (task) => { - if (task.status === 'complete' || task.status === 'failed' || task.status === 'aborted') { - unsub() - resolve(task) - } - }) - }) -} - -export function createSchedulerTriggerHandler( - fs: FirestoreSession, - fcm: Pick, - getExpoPushToken: (firebaseUid: string) => Promise, - creditService: Pick, - resolveUserId: (firebaseUid: string) => Promise, - opts: SchedulerTriggerOptions, -) { - const timeoutMs = opts.schedulerTimeoutMs ?? 60_000 - - return async (req: Request, res: Response): Promise => { - const authHeader = req.headers.authorization ?? '' - const token = authHeader.toLowerCase().startsWith('bearer ') ? authHeader.slice(7).trim() : '' - if (!token || token !== opts.secret) { - res.status(401).json({ error: 'Unauthorized' }) - return - } - - const parsed = schedulerBodySchema.safeParse(req.body) - if (!parsed.success) { - res.status(400).json({ error: 'Invalid request body' }) - return - } - - const { uid, action, actionSummary, notificationBody } = parsed.data - const typedAction = action as SingleAction | SequenceAction - - const blocked = findBlockedNavigation(typedAction) - if (blocked) { - res.status(422).json({ error: `HOST_NOT_ALLOWED: ${blocked.message}` }) - return - } - - let device: { deviceId: string; fcmToken: string; deviceName: string } | null - try { - device = await fs.getActiveDevice(uid) - } catch (err) { - console.error('[scheduler-trigger] getActiveDevice error:', err) - res.status(500).json({ error: 'Internal server error' }) - return - } - - if (!device) { - res.status(422).json({ error: 'No active device for this user' }) - return - } - - const sessionId = crypto.randomUUID() - const taskId = crypto.randomUUID() - const requiresAuth = intentRequiresAuth(actionSummary, typedAction) - const taskIntent = { version: '1' as const, taskId, sessionId, requiresAuth, actionSummary, action: typedAction } - - try { - await fs.createSession(uid, sessionId, { status: 'pending', trigger: 'scheduler', voiceInstanceId: INSTANCE_ID }) - await fs.writeTask(uid, sessionId, taskId, taskIntent) - await fcm.wakeExtension(device.fcmToken, sessionId, taskId) - } catch (err) { - console.error('[scheduler-trigger] setup error:', err) - try { await fs.closeSession(uid, sessionId, 'aborted') } catch { /* ignore */ } - res.status(500).json({ error: 'Internal server error' }) - return - } - - let task: TaskDoc | null = null - try { - task = await Promise.race([ - watchTaskPromise(fs, uid, sessionId, taskId), - new Promise((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), timeoutMs)), - ]) - } catch { - // Timeout — task did not complete - try { await fs.abortPendingTaskIfOffline(uid, sessionId, taskId, { - taskId, status: 'failed', data: {}, activeUrl: '', - error: { code: 'EXECUTION_TIMEOUT', message: 'Scheduler task timed out', failedAction: typedAction as never }, - }) } catch { /* ignore */ } - try { await fs.closeSession(uid, sessionId, 'aborted') } catch { /* ignore */ } - res.status(504).json({ error: 'Task timed out', sessionId, taskId }) - return - } - - try { await fs.closeSession(uid, sessionId, 'closed') } catch { /* ignore */ } - - const pushBody = task.status === 'complete' - ? notificationBody - : `Browser task failed (${task.error?.code ?? 'unknown'}). Tap to check.` - - try { - const expoPushToken = await getExpoPushToken(uid) - if (expoPushToken) { - await fcm.sendProactive(expoPushToken, sessionId, taskId, pushBody) - } - } catch (err) { - console.error('[scheduler-trigger] sendProactive error:', err) - } - - res.json({ ok: true, sessionId, taskId, status: task.status }) - } -} -``` - -- [ ] **Step 4.4: Run tests to confirm all pass** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'scheduler|FAIL' | head -20 -``` - -Expected: all 7 scheduler tests pass, no FAIL lines. - -- [ ] **Step 4.5: Commit** - -```bash -git add cloud-agent/src/handlers/schedulerTriggerHandler.ts cloud-agent/src/handlers/schedulerTriggerHandler.test.ts -git commit -m "feat(bridge): add schedulerTriggerHandler for Cloud Scheduler proactive browser tasks" -``` - ---- - -## Task 5: Wire `POST /agent/browser/scheduler-trigger` into `index.ts` - -**Files:** -- Modify: `cloud-agent/src/index.ts` -- Modify: `cloud-agent/src/index.test.ts` - -- [ ] **Step 5.1: Write failing test** - -In `cloud-agent/src/index.test.ts`, locate the existing test helper section and add a test after existing route tests: - -```typescript -test('POST /agent/browser/scheduler-trigger returns 401 with no secret', async () => { - const db = makeMockDb() - const app = createApp({ - verifyToken: async () => ({ uid: 'uid' }), - db, - runAgentFn: async () => ({ reply: 'ok', toolCalls: [] }), - }) - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'p' }, actionSummary: 'Extract', notificationBody: 'Done' }) - assert.equal(res.status, 401) -}) - -test('POST /agent/browser/scheduler-trigger returns 503 when SCHEDULER_SECRET not set', async () => { - const saved = process.env.SCHEDULER_SECRET - delete process.env.SCHEDULER_SECRET - const db = makeMockDb() - const app = createApp({ - verifyToken: async () => ({ uid: 'uid' }), - db, - runAgentFn: async () => ({ reply: 'ok', toolCalls: [] }), - }) - const res = await request(app) - .post('/agent/browser/scheduler-trigger') - .set('Authorization', 'Bearer anything') - .send({ uid: 'u1', action: { type: 'extract', selector: '.p', label: 'p' }, actionSummary: 'Extract', notificationBody: 'Done' }) - assert.equal(res.status, 503) - process.env.SCHEDULER_SECRET = saved -}) -``` - -- [ ] **Step 5.2: Run to confirm tests fail** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'scheduler-trigger.*401|scheduler-trigger.*503|FAIL' | head -5 -``` - -Expected: tests fail because route doesn't exist yet (`404`). - -- [ ] **Step 5.3: Add imports to `index.ts`** - -In `cloud-agent/src/index.ts`, add after existing imports: - -```typescript -import { createSchedulerTriggerHandler } from './handlers/schedulerTriggerHandler.js' -import { getExpoPushToken } from './handlers/expoPushToken.js' -``` - -- [ ] **Step 5.4: Add route to `createApp` in `index.ts`** - -In `createApp`, after the `POST /agent/browser/approve-action` route block (before `return app`), add: - -```typescript - app.post('/agent/browser/scheduler-trigger', async (req: Request, res: Response): Promise => { - const secret = process.env.SCHEDULER_SECRET - if (!secret) { - res.status(503).json({ error: 'Scheduler trigger not configured' }) - return - } - if (!browserBridgeAvailable) { - res.status(503).json({ error: 'Browser bridge unavailable' }) - return - } - const handler = createSchedulerTriggerHandler( - defaultFirestoreSession(), - defaultFcmDispatcher(), - (firebaseUid: string) => getExpoPushToken(db, firebaseUid), - cs, - async (firebaseUid: string) => { - const [u] = await db.select({ id: users.id }).from(users).where(eq(users.firebaseUid, firebaseUid)) - return u?.id ?? null - }, - { secret }, - ) - return handler(req, res) - }) -``` - -- [ ] **Step 5.5: Run tests to confirm passing** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'scheduler-trigger|FAIL' | head -10 -``` - -Expected: both new scheduler-trigger index tests pass. - -- [ ] **Step 5.6: Commit** - -```bash -git add cloud-agent/src/index.ts cloud-agent/src/index.test.ts -git commit -m "feat(bridge): wire POST /agent/browser/scheduler-trigger route" -``` - ---- - -## Task 6: Update `wsLiveAgentHandler.ts` `getExpoPushToken` in production wiring - -The production wiring in `index.ts` needs to pass `getExpoPushToken` to `handleLiveWsUpgrade`. Currently it uses the default DB lookup internally, but with Task 3's injectable option, the test can override it. For production, no change is needed since the handler falls back to `dbGetExpoPushToken(options.db, fbUid)` when `options.getExpoPushToken` is not provided. Verify the fallback works. - -**Files:** -- Modify: `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts` (one more integration test) - -- [ ] **Step 6.1: Write test for production path (no `getExpoPushToken` override, handler uses DB lookup)** - -Append to `cloud-agent/src/handlers/wsLiveAgentHandler.test.ts`: - -```typescript -test('pushToLive uses DB lookup for expoPushToken when getExpoPushToken not injected', { timeout: 5000 }, async () => { - // Build a mock DB that returns an expoPushToken row for expoPushToken queries. - // The mock DB in wsLiveAgentHandler test calls `select().from().where().limit()` - // The `getExpoPushToken` helper does: db.select({ expoPushToken }).from(users).where(eq(users.firebaseUid, uid)).limit(1) - const expoPushRow = [{ expoPushToken: 'ExponentPushToken[db]' }] - const db = makeMockDb([[mockUser], [mockCharacter], expoPushRow]) - - const proactiveCalls: Array<{ token: string }> = [] - const mockFcmDispatcher = { - wakeExtension: async () => {}, - sendTaskComplete: async (token: string) => { proactiveCalls.push({ token }) }, - sendProactive: async () => {}, - } - - let watchTaskCallback: ((task: unknown) => void) | null = null - const mockFirestoreSession = { - getActiveDevice: async () => ({ deviceId: 'd1', fcmToken: 'tok', deviceName: 'Mac' }), - createSession: async () => {}, - writeTask: async () => {}, - closeSession: async () => {}, - writeTaskResult: async () => {}, - getTask: async () => ({ status: 'pending' }), - getSession: async () => ({ browserInstanceId: null, browserConnectedAt: null }), - abortPendingTaskIfOffline: async () => false, - watchTask: (_u: string, _s: string, _t: string, cb: (task: unknown) => void) => { - watchTaskCallback = cb - return () => {} - }, - } - - const mock = makeMockLiveConnect() - // No getExpoPushToken override — handler must fall back to DB lookup - const { server, close } = createLiveTestServer({ - db, - creditService: mockCreditService, - verifyToken: async () => ({ uid: 'fb-uid-2' }), - liveConnect: mock.connect, - browserBridge: { - firebaseUid: 'fb-uid-2', - userId: 'user-uuid-1', - firestoreSession: mockFirestoreSession as never, - fcmDispatcher: mockFcmDispatcher as never, - creditService: mockCreditService, - instanceId: 'inst-2', - wakeTimeoutMs: 50, - textTimeoutMs: 500, - }, - }) - const port = await listen(server) - - await new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`) - const timeout = setTimeout(() => reject(new Error('test timeout')), 4500) - ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token: 'v', characterId: CHAR_UUID }))) - ws.on('message', (raw) => { - const msg = JSON.parse(raw.toString()) as { type: string } - if (msg.type !== 'session_ready') return - mock.triggerMessage({ - toolCall: { functionCalls: [{ id: 'c2', name: 'browser_action', args: { - actionSummary: 'Extract', intent: { action: { type: 'extract', selector: '.p', label: 'p' } }, - }}] }, - }) - setTimeout(() => ws.close(), 20) - }) - ws.on('close', async () => { - await new Promise((r) => setTimeout(r, 100)) - watchTaskCallback?.({ status: 'complete', result: { data: { p: 'x' }, activeUrl: 'https://a.com' }, error: null, intent: { action: { type: 'extract', selector: '.p' } } }) - await new Promise((r) => setTimeout(r, 100)) - clearTimeout(timeout) - try { - assert.equal(proactiveCalls.length, 1) - assert.equal(proactiveCalls[0].token, 'ExponentPushToken[db]') - resolve() - } catch (e) { reject(e) } - }) - ws.on('error', reject) - }) - - await close() -}) -``` - -- [ ] **Step 6.2: Run to confirm passing** - -```bash -cd cloud-agent && npm test 2>&1 | grep -E 'DB lookup|FAIL' | head -5 -``` - -Expected: `pushToLive uses DB lookup...` → pass. - -- [ ] **Step 6.3: Run full test suite** - -```bash -cd cloud-agent && npm test 2>&1 | tail -15 -``` - -Expected: all tests pass, `0 failures`. - -- [ ] **Step 6.4: Commit** - -```bash -git add cloud-agent/src/handlers/wsLiveAgentHandler.test.ts -git commit -m "test(bridge): verify pushToLive Expo Push fallback uses DB lookup when not injected" -``` - ---- - -## Task 7: Cloud Scheduler Setup (Infrastructure — No Code) - -This task configures the GCP-side infrastructure. It is manual and not automated by tests. - -- [ ] **Step 7.1: Set `SCHEDULER_SECRET` environment variable on Cloud Run** - -In GCP Console → Cloud Run → `cloud-agent` service → Edit & Deploy New Revision → Variables & Secrets: - -Add environment variable: - -```bash -SCHEDULER_SECRET = -``` - -Store the secret in 1Password as `cloud-agent/SCHEDULER_SECRET`. - -- [ ] **Step 7.2: Create Cloud Scheduler job** - -In GCP Console → Cloud Scheduler → Create Job: - -```text -Name: browser-bridge-price-monitor -Region: us-central1 -Frequency: 0 * * * * (hourly, adjust per use case) -Target: HTTP -URL: https:///agent/browser/scheduler-trigger -HTTP method: POST -Body: { - "uid": "", - "runKey": "-", - "action": { - "type": "extract", - "selector": ".price", - "label": "price" - }, - "actionSummary": "Extract current price from open tab", - "notificationBody": "Price check complete. Tap to review." - } -Auth header: Add header - Authorization: Bearer - Content-Type: application/json -``` - -- [ ] **Step 7.3: Trigger the job manually and verify end-to-end** - -GCP Console → Cloud Scheduler → Force Run the job. Verify: -1. Cloud Run logs show `[scheduler-trigger]` entries -2. Extension wakes and executes the extract action -3. Mobile device receives `PROACTIVE_TASK` Expo Push notification -4. Tap notification → opens `/talk` deep link - -This is the Phase 3 gate: **1 working scheduled monitoring task**. - ---- - -## Self-Review - -**Spec coverage check:** - -| Spec requirement | Covered by | -|-----------------|-----------| -| Cloud Scheduler triggers | Task 4 + Task 5 + Task 7 | -| `sendProactive` Expo Push | Task 1 | -| Expo Push async completion (voice session closed) | Task 3 | -| `trigger: 'scheduler'` in SessionDoc | Already in `dsl-types.ts` — no change | -| Proactive FCM payload shape (`PROACTIVE_TASK`, high priority, `categoryIdentifier`) | Task 1 | -| Phase 3 gate: 1 working scheduled monitoring task | Task 7 | - -**`open question` from spec resolved:** Cloud Scheduler uses same `TaskIntent` DSL envelope — no new envelope needed. - -**Placeholder scan:** None — all steps include concrete code. - -**Type consistency:** -- `sendProactive(expoPushToken, sessionId, taskId, body)` — consistent between Task 1 (definition) and Task 4 (usage) -- `pushToLive(taskId, sessionId, text)` — consistent between Task 2 (definition change) and Task 3 (usage) -- `createSchedulerTriggerHandler(fs, fcm, getExpoPushToken, creditService, resolveUserId, opts)` — consistent between Task 4 (definition) and Task 5 (wiring) -- `FcmDispatcher` type is `ReturnType` — adding `sendProactive` to the factory auto-updates the type diff --git a/docs/superpowers/plans/2026-06-30-cws-extension-readiness.md b/docs/superpowers/plans/2026-06-30-cws-extension-readiness.md deleted file mode 100644 index 9beab8ee..00000000 --- a/docs/superpowers/plans/2026-06-30-cws-extension-readiness.md +++ /dev/null @@ -1,512 +0,0 @@ -# CWS Extension Readiness Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Land five targeted code changes in the extension and produce a CWS submission artifacts doc so the extension passes Chrome Web Store review. - -**Architecture:** Three source files change (`manifest.json`, `panel.ts`, `auth-bridge.ts`); a new CWS artifacts doc captures copy-paste store listing content; the build script already copies source `manifest.json` to `dist/` so no build script changes are needed. - -**Tech Stack:** TypeScript, Node.js test runner (tsx/esm), esbuild, Chrome Extension MV3 - ---- - -## File Map - -| Action | Path | -|--------|------| -| Modify | `extension/manifest.json` | -| Modify | `extension/src/ui/side-panel/panel.ts` | -| Modify | `extension/src/background/auth-bridge.ts` | -| Create | `extension/src/ui/side-panel/panel.test.ts` | -| Create | `docs/cws-submission-artifacts.md` | - ---- - -### Task 1: Retrieve extension public key from 1Password - -The `key` field in `manifest.json` requires the base64 public key derived from the extension's `.pem` signing file stored in 1Password. This task has no code changes — it produces the value needed for Task 2. - -**Files:** (none — output is a string you copy into Task 2) - -- [ ] **Step 1: Retrieve `.pem` from 1Password** - -Open 1Password and find the Clanker Desktop Bridge signing key (look for "extension key.pem" or "clanker-extension"). Download or copy the `.pem` file to a temporary location (e.g., `/tmp/key.pem`). - -- [ ] **Step 2: Extract the base64 public key** - -```bash -openssl rsa -in /tmp/key.pem -pubout -outform DER | base64 | tr -d '\n' -``` - -Expected output: a long base64 string (no newlines). Copy this value — you will paste it as `` in Task 2. - -- [ ] **Step 3: Delete the temp pem file** - -```bash -rm /tmp/key.pem -``` - ---- - -### Task 2: Update `extension/manifest.json` — icons, key, CSP - -**Files:** -- Modify: `extension/manifest.json` - -- [ ] **Step 1: Write the updated manifest** - -Replace the entire content of `extension/manifest.json` with: - -```json -{ - "manifest_version": 3, - "name": "Clanker Desktop Bridge", - "version": "0.1.0", - "description": "Lets your Clanker agent perform web tasks you request on this browser.", - "key": "", - "background": { "service_worker": "background/service-worker.js", "type": "module" }, - "content_scripts": [], - "gcm_sender_id": "54051268985", - "permissions": ["scripting", "tabs", "storage", "sidePanel", "notifications", "gcm", "offscreen"], - "optional_host_permissions": [""], - "side_panel": { "default_path": "ui/side-panel/index.html" }, - "action": { - "default_popup": "ui/popup/index.html", - "default_icon": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - } - }, - "icons": { - "16": "icons/icon-16.png", - "48": "icons/icon-48.png", - "128": "icons/icon-128.png" - }, - "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self';" - } -} -``` - -Replace `` with the value obtained in Task 1 Step 2. - -If the `.pem` is not yet available (e.g. first-time submission), omit the `key` field for now and add it before uploading to CWS. - -- [ ] **Step 2: Verify no `eval`/remote CSP violations exist** - -```bash -grep -rE "eval\(|new Function\(|importScripts\(" extension/src/ -``` - -Expected: no matches. - -- [ ] **Step 3: Commit** - -```bash -git add extension/manifest.json -git commit -m "feat(extension): add icons, key, and CSP to manifest for CWS submission" -``` - ---- - -### Task 3: Replace `innerHTML` write in `panel.ts` - -The CWS automated scanner flags `innerHTML` with string-concatenated content. Replace the `renderLog` function body with safe DOM insertion. - -**Files:** -- Modify: `extension/src/ui/side-panel/panel.ts:87-88` -- Create: `extension/src/ui/side-panel/panel.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `extension/src/ui/side-panel/panel.test.ts`: - -```typescript -import test from 'node:test' -import assert from 'node:assert/strict' -import { JSDOM } from 'jsdom' - -function makeLog(entries: Array<{ ts: number; action: string; status: string }>): Element { - const dom = new JSDOM('
      ') - const document = dom.window.document - const logEl = document.getElementById('log')! - logEl.textContent = '' - for (const entry of entries) { - const li = document.createElement('li') - li.textContent = `${new Date(entry.ts).toLocaleTimeString()} ${entry.action} ${entry.status === 'complete' ? '✓' : '✕'}` - logEl.appendChild(li) - } - return logEl -} - -test('renderLog creates li elements, not innerHTML strings', () => { - const ts = new Date('2026-01-01T12:00:00Z').getTime() - const logEl = makeLog([ - { ts, action: 'READ_PAGE', status: 'complete' }, - { ts, action: 'CLICK', status: 'failed' }, - ]) - const items = logEl.querySelectorAll('li') - assert.equal(items.length, 2) - assert.match(items[0].textContent ?? '', /READ_PAGE/) - assert.match(items[0].textContent ?? '', /✓/) - assert.match(items[1].textContent ?? '', /CLICK/) - assert.match(items[1].textContent ?? '', /✕/) -}) - -test('renderLog clears previous entries before rendering', () => { - const ts = Date.now() - const logEl = makeLog([{ ts, action: 'FIRST', status: 'complete' }]) - // Simulate a second render by running the same logic - logEl.textContent = '' - const li = logEl.ownerDocument.createElement('li') - li.textContent = 'SECOND' - logEl.appendChild(li) - assert.equal(logEl.querySelectorAll('li').length, 1) - assert.match(logEl.querySelector('li')?.textContent ?? '', /SECOND/) -}) - -test('XSS payload in action field is not executed as HTML', () => { - const ts = Date.now() - const logEl = makeLog([{ ts, action: '', status: 'complete' }]) - const li = logEl.querySelector('li') - assert.ok(li?.textContent?.includes('