* chore: remove legacy orchestrator, trading and one-off script leftovers The repository still carried the pre-evOo prototype: a Python orchestrator, the old HTML "evoo-platform" cockpit (whose test ran in the root unit suite), an apex-trading package, the hot-reload deploy.sh for it, a permissions manifest, an architecture note with local file links, agent handoff notes, a Steve Jobs persona template and about ninety one-off scripts that probe a developer database with hard-coded local URLs. None of it is used by the product, CI or the real deploy in ops/evo-deploy. Delete those files, drop the evoo-platform test glob from the root vitest config, the Cockpit section from .env.example and the dead biome and gitignore entries, and update the docs that listed the removed tools. scripts/start-tmux.sh no longer starts the removed sentinel and resolves the checkout from its own location instead of a machine-specific path; scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a local URL with a username. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): drop trading and persona prompts from the conversation shell The web shell still showed leftovers from the trading prototype: composer chips with German trading and "Steve Jobs" prompts, trading suggestion chips under the empty conversation line, a German trading hero for the empty transcript, and a German "data protection" status panel in the sidebar. They suggested actions the product does not have and added persistent chrome. Remove the composer chips and their helper, the empty-state chips and the hero (the empty conversation keeps its existing one-line start hint), the sidebar trading indicator and the data trust panel. The new-bot E2E now asserts the plain empty state; the sidebar spec no longer expects the trading status, and the placeholder Steve Jobs spec with no assertions is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor: remove the trading bot status integration With the sidebar trading indicator gone, the only remaining consumer of the trading bot status was a banner in General settings that reused the same component. evOo is a personal assistant; a status proxy and poller for one external trading service, configured through service-specific TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in the core product. Remove the settings banner and its prop plumbing, the web poller, the indicator component, the /api/trading-bot/status proxy, the shared core contract, the env loading and both variables from .env.example, together with their tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: remove one-off database scripts and a stale review note packages/db carried four hand-run scripts outside the package build that query or seed a developer database with a hard-coded connection string, one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a one-time verification note for a workflow that no longer exists and named private machines and local paths. Nothing references them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(memory): remember appends dated notes instead of replacing memory The remember tool rewrote the bot's whole MEMORY.md with each call, so every new fact erased the earlier ones unless the model re-sent the full document. - MemoryCommitRequest gains mode "append" (the store joins the note onto the current content inside the same serializable transaction and still writes a revision row) and every commit is capped at 16 KB per document. - remember appends a dated bullet ("- YYYY-MM-DD: ...", the user's local date) by default and rewrites only with mode "replace"; a full document returns an error that tells the bot to condense it. Retries of the same tool call stay single appends through the existing effect record. - Scripted runtime memory entries can append too; the scripted "remember" script now appends instead of overwriting the bot's memory. - When memory does not fit the run's 32 KB budget, the prompt keeps a document's newest lines instead of its oldest. - memory.update maps an oversized save to BAD_REQUEST. - docs/PRODUCT.md records that UserFact is the cross-space user model and the space MEMORY.md stays space notes (no migration). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(coach): goals, habits, check-ins, daily briefs and check-in rhythm The main agent should coach the user, but nothing stored what the user wants to achieve or keep doing, and no routine ever checked in. This adds the data model and the interfaces the onboarding intake and the coach tools build on. - Schema: UserGoal, Habit, CheckIn (one per habit, goal or day and date) and DailyBrief, all keyed to the user's local calendar date, plus a coachKey on Routine that marks the main bot's coach check-ins. - Contracts (coach.ts): CoachRhythm, Goal, Habit, CheckIn, DailyBrief and CoachToday DTOs and the RPC inputs. - Repository (@rakazo/db coach.ts): goal, habit and check-in CRUD with per-user limits, upsertDailyBrief and loadCoachToday with streaks in the user's time zone. - coach-rhythm.ts: setCoachRhythm/getCoachRhythm keep up to three main bot routines (morning brief, evening check-in, weekly review) in the main conversation in step with the user's chosen times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(notifications): add push subscriptions, notification settings and web push interfaces Push could only reach one mobile device per user (a token file), quiet hours did not exist, and the web app had no service worker, so a closed tab never heard about finished work. This lands the shared interfaces first so onboarding can build on them: - push_subscriptions (web push endpoints and Expo tokens, one row per device) and user_notification_settings (quiet hours as minutes in the profile time zone, nudges, a per-day nudge counter), with CHECK constraints. - NotificationSettingsSchema and web push contracts, notifications.settings.get/update and notifications.webPush.config/subscribe/unsubscribe (owner-scoped, https endpoints only). - getNotificationSettings/updateNotificationSettings plus the policy and nudge-cap helpers. - VAPID keys from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or generated once and stored encrypted, and an SSRF-guarded web push sender built on the web-push package. - The web app links its manifest, registers a push-only service worker where supported (secure context, not the desktop app) and exposes webPushSupported/webPushEnabled/enableWebPush/ disableWebPush. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: replace machine-specific paths and addresses with placeholders ORIGIN.md pinned the intended checkout to an absolute path under a personal home directory, docs/LOCAL-SETUP.md listed folders on one developer's machine, and two tests used what looks like a real tailnet address. Use a home-relative path, drop the machine-specific folder list, and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10, so both tests check the same thing as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(coach): repository, streak and rhythm suites Covers what the coach interfaces promise before anything builds on them: goal and habit ownership and limits, one check-in per habit, goal and day dated in the user's time zone, streaks across weekends and weeks, and a rhythm that keeps exactly one main-bot routine per check-in and is idempotent when set again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor(contracts): share one clock-time schema The coach rhythm and the quiet hours both parse "HH:MM" wall-clock times and each defined its own ClockTimeSchema, which the contracts index cannot export twice. Both now import it from one module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): ask the onboarding questions in the main conversation The wizard asked for the account name again and a "Tell us about yourself" form, and the focus answer in the main conversation was never saved. The main bot now gets to know the user in the main conversation instead, one card after each answer. - UserOnboarding keeps the pending step, the focus and when onboarding finished. - onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications) as cards plus what their answers change; onboarding.ts commits an answer, the focus app suggestions and the next card or the closing line in one transaction. - The choice block gains a step, an optional text input and the typed answer; choose takes the step, the text and whether the device can still turn push on. - The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the push seam are placeholders until the coach and notification modules are merged. - The wizard drops the preferences step and fills time zone, locale and language from the browser; the main bot's prompt notes open onboarding cards; agent history shows a card's question and answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(notifications): fan push out to every device, honour quiet hours, add notify_user Run notifications reached at most one phone and nothing could hold them back at night. - PushNotificationProvider sends each notification to all of the user's registered devices (web push and Expo), skips everything during quiet hours in the profile time zone, drops devices the push service reports gone (404/410, DeviceNotRegistered), records lastUsedAt and links each push to its conversation. - Expo tokens move from DATA_DIR/push-tokens files into push_subscriptions: the API imports and deletes the legacy files at start; notifications.registerPush/unregisterPush keep their contract for the mobile app (tokens must look like Expo tokens). - NotificationMessage gains the nudge kind, and the main bot gets notify_user: a short push to the main conversation that respects the nudge switch, quiet hours and a cap of three per day. It is withheld from shared messaging channels and from runs answering another owner's agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(coach): main-bot coach tools and goals in its prompt The main agent had no way to record what the user wants to achieve or how their day went, and its prompt never showed their goals or habits. - update_goal, update_habit, log_check_in and write_daily_brief, main-bot only and, like update_user_profile, withheld from shared channels and from runs that answer another owner's agent. Goals and habits can be named by id or exact title. - The main bot's own runs list active goals and habits (with ids, done state and streak) in the <user> block; the basics-only block and every other bot never see them. The main agent role adds a short coaching stance and names each coach tool only when the run has it, so the check-in routine prompts stay tool-agnostic. - The scripted runtime understands a few coach commands and the morning check-in, so tests can drive the tools end to end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): use English Lingui sources for German UI text Several web surfaces hard-coded German (the project card, roster and activity status labels, the sidebar "Heute" label) or used German text as the Lingui source message (sidebar sections, project timeline, bot picker, voice entry, group stage, copy and read-aloud buttons). English users saw German, and because production builds keep only message ids, none of these strings could be translated per locale. Convert them to English source messages, keeping the meaning: - ProjectCreatedCard: Lingui Trans/Plural instead of literals. - agent-activity: labels go through i18n._ with literal ids so the module stays importable from plain unit tests; the extractor still collects them. - Shell, ProjectSessionTimeline, bot-picker, StageVoiceEntry, OoStage: English sources, reusing existing messages where one already fit ("just now", "{n}m ago", "Search", "No bots match", "Set up voice", "Group conversation", "Transcript"). - The conversation picker matches its create entry against the translated label instead of a fixed German string. The German wording returns through the de catalog in the next commit. Unit and E2E tests now assert the English labels. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): keep Lingui catalogs complete and in sync The committed catalogs were last extracted weeks ago: about 330 source messages were missing from every locale, and production builds keep only message ids, so those strings rendered as hash ids (sidebar sections, privacy settings, profile, timeline, ...). German also had 226 empty entries, and every extraction rewrote the files because references carried line numbers. - lingui.config.ts: po references without line numbers, and a stable sort that breaks Lingui's ordering ties (explicit ids, equal text) by id, so the output no longer depends on file-system order. - Re-extract all catalogs with --clean (drops 25-44 obsolete entries per locale; no existing translation changed). - Translate every German entry (du form, consistent with the existing catalog). Other locales keep empty entries and fall back to English. - i18n-catalog-sync.test.ts extracts into a copy of the catalogs and fails when the committed files differ or a German entry is empty, so stale catalogs fail the unit test job (offline, about 5 s). Adds @lingui/format-po (already installed through @lingui/cli) as a direct dev dependency to configure the formatter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor(memory): derive the run's memory budget from the document limit The prompt budget was a separate 32 KB constant; it is now twice the per-document limit, so a full bot document and a full space document keep fitting if either number changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(profile): edit facts in place in About you About you could only confirm or delete what the bots know about the user, so a fact that was slightly wrong or filed under the wrong category had to be deleted and taught again, and a fact could not be limited to some spaces from the app even though the profile supports it. - Each fact gets an Edit button that turns its row into an inline editor: wording, category, and (when the user has more than one space, or the fact is already limited) which spaces it shows in. Escape or Cancel leaves it unchanged; only changed fields are sent through profile.update editFacts. - A limited fact names its spaces next to its source. - The repository validates edits like new facts: a wording whose normalized form is empty or equals another fact of the user is refused, all edits of one update apply atomically, and the stored embedding is only dropped when the wording really changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(inbox): list and answer items from all of the user's spaces The inbox in the main conversation only showed asks, approvals, takeovers and unread threads of the current space. The main conversation lives in the home space, so anything a bot needed in a project space never reached it. - inbox.list now covers every space the user belongs to (except spaces being deleted), still only the user's own bots, runs and threads. Items from another space carry that space's id and name. - The web inbox shows the space name next to the bot for those items, answers them with that space's header so the existing thread procedures check membership and ownership for that space, and opens their thread in it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(coach): coach RPCs for goals, habits, check-ins, today and rhythm The web app needs to show and change the user's coach data, and the check-in times need a way to change after onboarding. - coach.today, coach.goals.*, coach.habits.*, coach.checkIns.* and coach.rhythm.get/set, owner-only and not space-scoped; another user's ids read as not found, invalid input as a bad request. - coach-rhythm reuses the shared clock-time helpers. - Tests: an API suite covering ownership and validation for every procedure, the coach tools driven through the main bot (and refused for a specialist), and a morning check-in routine that fires in the main conversation and writes today's brief; the unauthenticated authorization sweep lists the new procedures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): save goal, check-in rhythm, quiet hours and push from the cards With the coach and notification modules merged, the onboarding answers now take effect: - the goal card creates a goal (source onboarding) through createGoal; - the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the closing line names the first check-in from the routines that were saved; - the quiet-hours card updates the notification settings; - the choice card tells the server whether this browser can still turn push on, so the notifications card is offered only there, and "Turn on" subscribes before answering. The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can be written while the answer holds the lock. The goal answer is capped at the coach title length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a stale and a doubled answer, another bot's focus card, and another user's access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(notifications): cover quiet hours, delivery, nudges and the notification RPCs The quiet-hours CHECK accepted a row with only one bound set, because a NULL comparison made the whole condition NULL; it now requires both bounds explicitly. The migration has not shipped yet, so it is corrected in place. - Unit: quiet-hours math across midnight, time zones and daylight saving; VAPID key handling (configured, generated once and shared, invalid); the web push sender (encrypted VAPID-signed request, gone on 404/410, no private or non-https endpoints); the fan-out with fake senders. - PostgreSQL: settings and subscription repositories (constraints, per-user removal, endpoint hand-over, device cap, concurrent nudge cap, cascade on account deletion), delivery and notify_user against a real main conversation, and every notification RPC through the API including cross-user isolation and endpoint validation. - .env.example documents the optional VAPID keys and VAPID_SUBJECT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): notification settings, and sign-out stops this browser's push Users had no way to turn on push for a browser, set quiet hours or opt out of nudges. Settings gains a compact Notifications section: a switch for push on this device (only where web push works: a secure context outside the desktop app), quiet hours with from/to times (22:00-07:00 when first turned on) and a switch for nudges. Changes save in order as they are made. A browser that blocked notifications says so instead of offering a dead switch. Logging out unregisters the browser first, so a shared computer stops receiving the account's pushes. The E2E stubs the Push API (headless Chromium has no push service), walks all three controls, checks they survive a reload and captures the notification-settings screenshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(profile): keep the fact editor's Save label visible Small buttons in ui-web merge their text-ui-label size over the default variant's text color, so the small Save button rendered ink on ink. The editor now uses default-size controls, with the category select at the same height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * refactor(notifications): keep the push sender contract next to the fan-out The payload, target and outcome types are shared by the Expo and web push senders, so they now live with PushSender in push-notifications.ts instead of in the web push adapter. The settings E2E also stubs the Notification permission (headless Chromium reports it as denied even when granted), and the quiet-hours inputs are wide enough for 12-hour times. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(core): load child_process normally in the git checkout reader readGitCheckout loaded node:child_process through require(), which does not exist in the ESM API runtime. Linking a project space to a folder that is a git checkout therefore failed after the space was created. The lazy load is no longer needed: the reader is only reachable through the server-only "@rakazo/core/git-checkout" subpath, never through the package entry the web app bundles. Import spawn at the top instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): ask before discovering projects and let the user choose The onboarding scan started on its own, discarded what it found, claimed to read email and calendars, simulated progress for everyone who could not scan, and its project creation always failed (wrong Space fields and a space lookup by title across tenants). Discovery now asks first and creates nothing without a choice: - The scan step appears only for the deployment owner when ONBOARDING_SCAN_HOME is set; everyone else skips it. The simulated scan is gone. - It shows the folder and asks. Only directory listings are read: git projects up to four levels deep, their languages and a few tools (Docker, GitHub Actions, Terraform). Symlinks are never followed and each chosen folder is resolved again before use, so nothing outside the folder is reached. Results stay with the account that scanned. - Found projects are a checklist (none preselected); found languages and tools are facts (preselected). "Add" creates one project space per chosen project with createSpaceForMember and wireProjectWorkspace in the user's own organization, reusing the user's own space already linked to that folder, and saves the chosen facts as onboarding facts under the profile lock and fact limit. - When a connection becomes connected (direct connect, authorization round trip, or catalog reconciliation), "Uses <App>" is remembered as a tools fact from the connector. Revoking keeps it. The E2E harness now points ONBOARDING_SCAN_HOME at a fixture folder and starts without a deployment owner, so owner-only onboarding steps appear only in specs that take the seat through /__e2e/deployment-owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(web): coach on the Heute page Heute only listed runs and linked to a hard-coded local Mission Control address, so the coach's work had nowhere to show. - Today's brief at the top, habits with a check-off and their streak, and active goals, each only when there is something to show; then the existing needs-you, doing and done lists. - One icon button opens the check-in times (morning, evening, weekly), loaded when opened and saved on every change. - The page reads coach.today, refreshes it when a run finishes or the tab becomes visible, and checks habits off optimistically; the date header uses the user's time zone from the profile. - Removes the Mission Control link and its constant. - Tests: helper unit tests and a web E2E that writes the brief through the main bot, checks a habit off across a reload and sets check-in times (screenshots heute-coach and heute-rhythm). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(onboarding): walk the onboarding questions end to end; mobile reads cards as text - onboarding-intake.spec walks focus, goal (after typing into the composer, which still works), check-ins, quiet hours and the notifications card when the browser offers it, with the onboarding-intake-goal, -rhythm and -done screenshots. - completeOnboarding and the wizard specs drop the preferences step; the scan step is optional (skipped when it asks, otherwise it advances by itself). - The user-profile spec now checks the browser-filled profile and the focus fact; the conversation spec checks the shorter app suggestion and the following goal question. - The hover spec measures a reply that fills its row (a bot row's rail sits at the row's end, so the old long onboarding line was what made it pass) and scopes Send to the composer, since an open goal card has its own. - Mobile shows a choice card as its question and answer instead of an empty bubble. - The focus options drop their unused summary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): stub the Notification permission without a static-only class Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): find the sidebar Today button by its English label The shell contrast test added on main looks up the sidebar button by the old German source text, which this branch replaces with "Today". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * feat(onboarding): show the onboarding cards and check-in names in the user's language The onboarding cards, the closing line and the coach routine names were English server text for every user. The server keeps the state and an English fallback; the web now words them through Lingui, so there is one translation source. - Choice cards: the web says the question, the option labels and the placeholder from the step and option ids, and keeps the server text for anything it does not know (quiet-hour ranges are already written in the user's locale). Another bot's focus card is recognized by its option ids. Mobile and agent history keep the server text. - The "Got it. … are a good place to start." line is gone: the app cards speak for themselves, and with no cards nothing is said. - The closing line becomes an intake_note block (note "done", nextCheckInAt, English text). The web words it with the first check-in in the user's clock; mobile blockText and blocksToAgentHistoryText read its text through their generic fallback. - Routines carry coachKey. The routine list and editor show a check-in under its label in the UI language until the user renames it; saving an untouched label keeps the stored name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * style: use top-level type imports in the coach changes AGENTS.md asks for top-level `import type` instead of inline `type` specifiers; this applies it to the import statements the coach work added or changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore(web): extract catalogs and translate the coach strings to German Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): look for the bot reply in the transcript only The main conversation's sidebar row now previews the latest reply, so the unscoped text lookup matched the sidebar too and failed in strict mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): drop the trading status from the shell contrast test main's contrast test now waits for the sidebar trading status before measuring; this branch removes that status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(onboarding): link the foreign folder while its tenant owns the deployment Since #112 only the deployment owner can link a host folder, so the discovery test creates the other tenant's linked space while that tenant holds the deployment, then hands the deployment to the scanning owner. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore(web): extract catalogs and translate the in-app browser strings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: look up the home main conversation in the scheduling journey Since every project has its own main conversation, the journey that schedules for a specialist found the user's main conversation with findFirst over all of them. With the Lab project's conversation also matching, it picked either one, so the check failed about one run in three. The lookup now names the home space, where the specialist joined and the routine is created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test: expect the home main conversation's ask in another space's inbox Every project now has a main conversation of its own, so an ask waiting in the home main conversation is not visible from a project and its inbox lists it, answered in the home space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(coach): move check-ins when the user's time zone changes The coach check-ins are main-bot routines scheduled in the profile time zone, but a later zone change (About you, onboarding, the main bot's update_user_profile tool, or forgetting the whole profile) left them in the old zone until the rhythm was saved again, so they fired at the wrong local time. syncCoachTimeZone, next to the rhythm code, moves the main bot's coach routines that are not in the current profile zone into it, recomputes the next run of armed ones and re-arms their wakeup. Paused check-ins move but stay paused, routines the user made are never touched, users without check-ins get none, and a save that leaves the zone as it is changes nothing. The profile update and forget RPCs and the profile tool call it whenever they write the zone. Quiet hours, the daily nudge cap and coach dates are evaluated in the profile zone when they are used, so they need no migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): word the main conversation preview in the user's language during onboarding While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the server preview: nothing for a pending card (a card has no text of its own, so the row fell back to "Ready") and the English fallback of the closing note, even for a German user. Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding card or note, validated with the same block schemas the messages use (now named exports). The web words it with the helpers that render the card and the note, so no copy is duplicated: the question while a card waits, the answer once given, and the closing note in the user's language and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it. The field is left out for every other message, and mobile keeps using the plain `preview`. The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): look for the focus question in the conversation, not the sidebar The main conversation's sidebar row now shows the pending intake question, so a page-wide text match finds it twice (strict mode) or never reaches zero. The specs mean the card in the open conversation, so they look there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com>
2247 lines
82 KiB
TypeScript
2247 lines
82 KiB
TypeScript
vi.mock("./ai-consent", () => ({ promptAiConsent: vi.fn() }));
|
||
|
||
import * as SecureStore from "expo-secure-store";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import { promptAiConsent } from "./ai-consent";
|
||
import type { MobileMessage, MobileSnapshot } from "./api.js";
|
||
import {
|
||
adoptDeletedSpaceFallback,
|
||
applyMobileThreadEvent,
|
||
authHeaders,
|
||
blockText,
|
||
changePassword,
|
||
currentApiBase,
|
||
deleteAccount,
|
||
loadApiBase,
|
||
MAX_MOBILE_AUTH_RESPONSE_BYTES,
|
||
MAX_MOBILE_RPC_RESPONSE_BYTES,
|
||
mergeMobileSnapshot,
|
||
passwordResetCapabilities,
|
||
prependMobileMessagePage,
|
||
requestPasswordReset,
|
||
resetApiBase,
|
||
rpc,
|
||
saveApiBase,
|
||
selectedSpaceId,
|
||
selectInitialSpace,
|
||
selectSpace,
|
||
shouldApplyMobileThreadRefresh,
|
||
signIn,
|
||
signOut,
|
||
signUp,
|
||
subscribeThread,
|
||
} from "./api.js";
|
||
import { resumeLiveNotifications } from "./live-notifications.js";
|
||
import {
|
||
clearSessionToken,
|
||
restoreSessionToken,
|
||
saveSessionToken,
|
||
snapshotSessionToken,
|
||
} from "./session.js";
|
||
|
||
vi.mock("expo-secure-store", () => ({
|
||
getItemAsync: vi.fn(),
|
||
setItemAsync: vi.fn(),
|
||
deleteItemAsync: vi.fn(),
|
||
}));
|
||
vi.mock("./live-notifications.js", () => ({
|
||
resumeLiveNotifications: vi.fn(async () => undefined),
|
||
stopLiveNotifications: vi.fn(async () => undefined),
|
||
}));
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe("mobile API authentication", () => {
|
||
beforeEach(async () => {
|
||
vi.restoreAllMocks();
|
||
vi.mocked(SecureStore.getItemAsync).mockReset();
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
vi.mocked(resumeLiveNotifications).mockClear();
|
||
await restoreSessionToken("");
|
||
});
|
||
|
||
it("persists a successful sign-in token and sends the native origin", async () => {
|
||
const fetchMock = vi.fn(async () => jsonResponse({ token: "session-token" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await signIn("ada@example.com", "correct horse");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/sign-in/email",
|
||
expect.objectContaining({
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://" },
|
||
body: JSON.stringify({ email: "ada@example.com", password: "correct horse" }),
|
||
}),
|
||
);
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "session-token");
|
||
expect(resumeLiveNotifications).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("creates an account and persists its session token", async () => {
|
||
const fetchMock = vi.fn(async () => jsonResponse({ token: "signup-token" }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await signUp("new@example.com", "correct horse", "New User");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/sign-up/email",
|
||
expect.objectContaining({
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://" },
|
||
body: JSON.stringify({
|
||
email: "new@example.com",
|
||
password: "correct horse",
|
||
name: "New User",
|
||
}),
|
||
}),
|
||
);
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "signup-token");
|
||
});
|
||
|
||
it("loads password recovery capability and requests a server-approved redirect", async () => {
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(
|
||
jsonResponse({ passwordReset: true, resetUrl: "https://rakazo.test/reset-password" }),
|
||
)
|
||
.mockResolvedValueOnce(jsonResponse({ status: true }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(passwordResetCapabilities()).resolves.toEqual({
|
||
passwordReset: true,
|
||
resetUrl: "https://rakazo.test/reset-password",
|
||
});
|
||
await requestPasswordReset("ada@example.test", "https://rakazo.test/reset-password");
|
||
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
"http://127.0.0.1:3100/api/auth/request-password-reset",
|
||
expect.objectContaining({
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
email: "ada@example.test",
|
||
redirectTo: "https://rakazo.test/reset-password",
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("treats a malformed capabilities response as password recovery being unavailable", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response("not-json", { status: 200 })),
|
||
);
|
||
|
||
await expect(passwordResetCapabilities()).resolves.toEqual({
|
||
passwordReset: false,
|
||
resetUrl: null,
|
||
});
|
||
});
|
||
|
||
it("changes a password with the bearer session and revokes other sessions", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi.fn(async () => jsonResponse({ status: true }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await changePassword("old-password", "new-password");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/change-password",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ authorization: "Bearer session-token" }),
|
||
body: JSON.stringify({
|
||
currentPassword: "old-password",
|
||
newPassword: "new-password",
|
||
revokeOtherSessions: true,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it("does not send a password or bearer token to a persisted public HTTP server", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") return "http://app.example.test";
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
const fetchMock = vi.fn(async () => jsonResponse({ status: true }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await loadApiBase();
|
||
await changePassword("old-password", "new-password");
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/api/auth/change-password",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ authorization: "Bearer session-token" }),
|
||
}),
|
||
);
|
||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||
expect.stringMatching(/^http:\/\/app\.example\.test/),
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("starts notifications only after the inbox selects the default space", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) =>
|
||
key === "rakazo.session_token" ? "session-token" : null,
|
||
);
|
||
|
||
await expect(selectInitialSpace("space-default")).resolves.toBe(true);
|
||
|
||
expect(selectedSpaceId()).toBe("space-default");
|
||
expect(resumeLiveNotifications).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100",
|
||
"session-token",
|
||
"space-default",
|
||
);
|
||
});
|
||
|
||
it("surfaces the server message and does not persist a failed sign-in", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => jsonResponse({ message: "Invalid credentials" }, { status: 401 })),
|
||
);
|
||
|
||
await expect(signIn("ada@example.com", "wrong")).rejects.toThrow("Invalid credentials");
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("does not retain an oversized sign-in response", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () =>
|
||
jsonResponse(
|
||
{ token: "must-not-be-read" },
|
||
{ headers: { "content-length": String(MAX_MOBILE_AUTH_RESPONSE_BYTES + 1) } },
|
||
),
|
||
),
|
||
);
|
||
|
||
await expect(signIn("ada@example.com", "correct horse")).rejects.toThrow(
|
||
`exceeds ${MAX_MOBILE_AUTH_RESPONSE_BYTES} bytes`,
|
||
);
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("times out and cancels a stalled sign-in response body", async () => {
|
||
vi.useFakeTimers();
|
||
const cancel = vi.fn();
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(new ReadableStream({ cancel }))),
|
||
);
|
||
|
||
const pending = signIn("ada@example.com", "correct horse");
|
||
const rejection = expect(pending).rejects.toThrow("Request timed out");
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
|
||
await rejection;
|
||
expect(cancel).toHaveBeenCalledOnce();
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("reports an rpc that hit its timeout as a timeout, not as a canceled fetch", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(
|
||
(_input: unknown, init?: { signal?: AbortSignal }) =>
|
||
new Promise((_resolve, reject) => {
|
||
init?.signal?.addEventListener("abort", () =>
|
||
reject(new Error("fetch failed: FetchRequestCanceledException")),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
|
||
const pending = rpc("computer/status", { botId: "bot" });
|
||
const rejection = expect(pending).rejects.toThrow("Request timed out");
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await rejection;
|
||
});
|
||
|
||
it("reports a stalled rpc response body as a timeout", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const cancel = vi.fn();
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(new ReadableStream({ cancel }))),
|
||
);
|
||
|
||
const pending = rpc("computer/status", { botId: "bot" });
|
||
const rejection = expect(pending).rejects.toThrow("Request timed out");
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await rejection;
|
||
expect(cancel).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("reports a caller's cancellation with the caller's reason", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(
|
||
(_input: unknown, init?: { signal?: AbortSignal }) =>
|
||
new Promise((_resolve, reject) => {
|
||
init?.signal?.addEventListener("abort", () =>
|
||
reject(new Error("fetch failed: FetchRequestCanceledException")),
|
||
);
|
||
}),
|
||
),
|
||
);
|
||
|
||
const external = new AbortController();
|
||
const pending = rpc("computer/status", { botId: "bot" }, { signal: external.signal });
|
||
const rejection = expect(pending).rejects.toThrow("screen closed");
|
||
await vi.advanceTimersByTimeAsync(0);
|
||
external.abort(new Error("screen closed"));
|
||
await rejection;
|
||
});
|
||
|
||
it("lets a call opt into a longer timeout", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi.fn(
|
||
(_input: unknown, init?: { signal?: AbortSignal }) =>
|
||
new Promise<Response>((resolve, reject) => {
|
||
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
|
||
setTimeout(() => resolve(new Response(JSON.stringify({ json: { ok: true } }))), 20_000);
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = rpc<{ ok: boolean }>("computer/boot", { botId: "bot" }, { timeoutMs: 120_000 });
|
||
await vi.advanceTimersByTimeAsync(20_000);
|
||
await expect(pending).resolves.toEqual({ ok: true });
|
||
});
|
||
|
||
it("clears the local session even when the sign-out request fails", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => Promise.reject(new Error("offline"))),
|
||
);
|
||
|
||
await expect(signOut()).resolves.toBeUndefined();
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
});
|
||
|
||
it("unregisters push delivery before invalidating the session", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: null }))
|
||
.mockResolvedValueOnce(jsonResponse({}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await signOut();
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||
"http://127.0.0.1:3100/rpc/notifications/unregisterPush",
|
||
"http://127.0.0.1:3100/api/auth/sign-out",
|
||
]);
|
||
});
|
||
|
||
it("continues sign-out when push unregistration times out", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockImplementationOnce(
|
||
(_url, options: RequestInit) =>
|
||
new Promise((_resolve, reject) => {
|
||
options.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||
once: true,
|
||
});
|
||
}),
|
||
)
|
||
.mockResolvedValueOnce(jsonResponse({}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = signOut();
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await pending;
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||
"http://127.0.0.1:3100/rpc/notifications/unregisterPush",
|
||
"http://127.0.0.1:3100/api/auth/sign-out",
|
||
]);
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
});
|
||
|
||
it("clears the local session when the sign-out request stalls", async () => {
|
||
vi.useFakeTimers();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: null }))
|
||
.mockImplementationOnce(() => new Promise<Response>(() => undefined));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = signOut();
|
||
await vi.advanceTimersByTimeAsync(8_000);
|
||
await pending;
|
||
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.space_id");
|
||
});
|
||
|
||
it("unregisters push delivery before deleting the account", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: null }))
|
||
.mockResolvedValueOnce(jsonResponse({}));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await deleteAccount("correct horse");
|
||
|
||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||
"http://127.0.0.1:3100/rpc/notifications/unregisterPush",
|
||
"http://127.0.0.1:3100/api/auth/delete-user",
|
||
]);
|
||
});
|
||
|
||
it("sends authenticated RPC input and reports structured RPC errors", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ json: { ok: true } }))
|
||
.mockResolvedValueOnce(
|
||
jsonResponse({ error: { message: "Bot does not exist" } }, { status: 404 }),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc<{ ok: boolean }>("bots/get", { botId: "bot-1" })).resolves.toEqual({
|
||
ok: true,
|
||
});
|
||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||
1,
|
||
"http://127.0.0.1:3100/rpc/bots/get",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ authorization: "Bearer session-token" }),
|
||
body: JSON.stringify({ json: { botId: "bot-1" } }),
|
||
}),
|
||
);
|
||
await expect(rpc("bots/get", { botId: "missing" })).rejects.toThrow("Bot does not exist");
|
||
});
|
||
|
||
it("blocks mobile message and attachment submission when AI sharing is declined", async () => {
|
||
vi.mocked(promptAiConsent).mockResolvedValue(false);
|
||
const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) =>
|
||
jsonResponse({
|
||
json: {
|
||
scope: "account-space",
|
||
version: "2026-09-14",
|
||
recipients: [
|
||
{ key: "provider", name: "Example AI", use: "model", detail: "", allowed: false },
|
||
],
|
||
},
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
for (const proc of ["threads/send", "artifacts/create", "routines/create"]) {
|
||
await expect(rpc(proc, { botId: "bot-1", text: "private content" })).rejects.toThrow(
|
||
"AI data sharing",
|
||
);
|
||
}
|
||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||
for (const [url, init] of fetchMock.mock.calls) {
|
||
expect(String(url)).toContain("/rpc/aiConsent/status");
|
||
expect(init?.body).not.toContain("private content");
|
||
}
|
||
});
|
||
|
||
it.each([true, false])("explains the mobile upgrade requirement with JSON=%s", async (json) => {
|
||
const fetchMock = vi.fn(async () =>
|
||
json
|
||
? jsonResponse({ error: { message: "Not found" } }, { status: 404 })
|
||
: new Response("404 Not Found", { status: 404 }),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
await expect(rpc("threads/send", { botId: "bot" })).rejects.toThrow("Update your server");
|
||
expect(fetchMock).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it.each(["throws", "rejects", "stalls"])(
|
||
"reports the upgrade message when body cancellation %s",
|
||
async (mode) => {
|
||
const cancel = vi.fn(() => {
|
||
if (mode === "throws") throw new Error("cancel failed");
|
||
if (mode === "rejects") return Promise.reject(new Error("cancel failed"));
|
||
return new Promise<void>(() => {});
|
||
});
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => ({ status: 404, body: { cancel } })),
|
||
);
|
||
await expect(rpc("threads/send", { botId: "bot" })).rejects.toThrow("Update your server");
|
||
expect(cancel).toHaveBeenCalledOnce();
|
||
},
|
||
);
|
||
|
||
it("records mobile consent before submitting and uses the deployment policy URL", async () => {
|
||
vi.mocked(promptAiConsent).mockResolvedValue(true);
|
||
const calls: string[] = [];
|
||
const recipient = {
|
||
key: "provider",
|
||
name: "Example AI",
|
||
use: "model",
|
||
detail: "",
|
||
allowed: false,
|
||
};
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async (url) => {
|
||
const path = new URL(String(url)).pathname;
|
||
calls.push(path);
|
||
return jsonResponse({
|
||
json: path.endsWith("/status")
|
||
? {
|
||
scope: "account-space",
|
||
version: "2026-09-14",
|
||
recipients: [recipient],
|
||
privacyUrl: "https://example.com/privacy",
|
||
}
|
||
: { ok: true },
|
||
});
|
||
}),
|
||
);
|
||
await rpc("threads/send", { botId: "bot-1", text: "authorized content" });
|
||
expect(calls).toEqual(["/rpc/aiConsent/status", "/rpc/aiConsent/allow", "/rpc/threads/send"]);
|
||
expect(promptAiConsent).toHaveBeenLastCalledWith(recipient, "https://example.com/privacy");
|
||
});
|
||
|
||
it("rejects an oversized RPC response before parsing it", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () =>
|
||
jsonResponse(
|
||
{ json: { ok: true } },
|
||
{ headers: { "content-length": String(MAX_MOBILE_RPC_RESPONSE_BYTES + 1) } },
|
||
),
|
||
),
|
||
);
|
||
|
||
await expect(rpc("bots/get")).rejects.toThrow(`exceeds ${MAX_MOBILE_RPC_RESPONSE_BYTES} bytes`);
|
||
});
|
||
|
||
it("shares the selected space with direct API requests", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token");
|
||
await selectSpace("space-support");
|
||
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
});
|
||
|
||
it("does not switch spaces when the selection cannot be persisted", async () => {
|
||
await expect(selectSpace("space-support")).resolves.toBe(true);
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(selectSpace("space-social")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
await selectSpace("");
|
||
});
|
||
|
||
it("does not switch spaces when stale recovery cannot be cleared", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(selectSpace("space-social")).resolves.toBe(false);
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith("rakazo.space_id", "space-social");
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
await selectSpace("");
|
||
});
|
||
|
||
it("refuses sign-in when a previous space cannot be cleared", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked");
|
||
});
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => jsonResponse({ token: "new-session-token" })),
|
||
);
|
||
|
||
await expect(signIn("ada@example.com", "correct horse")).rejects.toThrow(
|
||
"Could not clear the previous space",
|
||
);
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.session_token",
|
||
"new-session-token",
|
||
);
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
await selectSpace("");
|
||
});
|
||
|
||
it("clears server-specific session and space state when the API endpoint changes", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockClear();
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: true });
|
||
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token");
|
||
expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.space_id");
|
||
await resetApiBase();
|
||
});
|
||
|
||
it("refuses to switch endpoints when SecureStore cannot clear credentials", async () => {
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked"));
|
||
vi.mocked(SecureStore.setItemAsync).mockRejectedValue(new Error("device locked"));
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.api_base",
|
||
"https://second-server.example",
|
||
);
|
||
});
|
||
|
||
it("restores notifications to the selected space when endpoint rollback succeeds", async () => {
|
||
const previousApiBase = currentApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-social");
|
||
await selectSpace("space-support");
|
||
vi.mocked(resumeLiveNotifications).mockClear();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.session_token" || (key === "rakazo.space_id" && value === "")) {
|
||
throw new Error("device locked");
|
||
}
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.api_base",
|
||
"https://second-server.example",
|
||
);
|
||
expect(resumeLiveNotifications).toHaveBeenCalledWith(
|
||
previousApiBase,
|
||
"session-token",
|
||
"space-support",
|
||
);
|
||
});
|
||
|
||
it("restores credentials when the new endpoint cannot be persisted", async () => {
|
||
const previous = currentApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not save the server URL",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
});
|
||
|
||
it("restores a persisted space when its initial load failed", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await loadApiBase();
|
||
const previous = currentApiBase();
|
||
let spaceReads = 0;
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key !== "rakazo.space_id") return null;
|
||
spaceReads += 1;
|
||
if (spaceReads === 1) throw new Error("device locked");
|
||
return "space-support";
|
||
});
|
||
await loadApiBase();
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not save the server URL",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.space_id", "space-support");
|
||
});
|
||
|
||
it("refuses an endpoint switch when the active space cannot be snapshotted", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await loadApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
return null;
|
||
});
|
||
await loadApiBase();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockClear();
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("preserves credentials when the active session cannot be snapshotted", async () => {
|
||
await saveSessionToken("session-token");
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.getItemAsync).mockRejectedValue(new Error("device locked"));
|
||
vi.mocked(SecureStore.deleteItemAsync).mockClear();
|
||
const previous = currentApiBase();
|
||
const next =
|
||
previous === "https://second-server.example"
|
||
? "https://third-server.example"
|
||
: "https://second-server.example";
|
||
|
||
await expect(saveApiBase(next)).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled();
|
||
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) =>
|
||
key === "rakazo.session_token" ? "session-token" : null,
|
||
);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
});
|
||
|
||
it("keeps an invalidated empty session fail closed during credential rollback", async () => {
|
||
await saveSessionToken("session-token");
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.session_token" && value === "") throw new Error("device locked");
|
||
});
|
||
await expect(clearSessionToken()).resolves.toBe(false);
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("stale-session-token");
|
||
const previous = currentApiBase();
|
||
const next =
|
||
previous === "https://second-server.example"
|
||
? "https://third-server.example"
|
||
: "https://second-server.example";
|
||
|
||
await expect(saveApiBase(next)).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the previous server session",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(snapshotSessionToken()).resolves.toEqual({ ok: true, value: "" });
|
||
|
||
await saveSessionToken("session-token");
|
||
});
|
||
|
||
it("keeps the in-memory session across consecutive failed endpoint switches", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.session_token" || (key === "rakazo.space_id" && value === "")) {
|
||
throw new Error("device locked");
|
||
}
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await expect(saveApiBase("https://third-server.example")).resolves.toMatchObject({ ok: false });
|
||
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith(
|
||
"rakazo.api_base",
|
||
expect.stringMatching(/second-server|third-server/),
|
||
);
|
||
});
|
||
|
||
it("restores credentials when resetting the endpoint cannot be persisted", async () => {
|
||
await saveApiBase("https://second-server.example");
|
||
const previous = currentApiBase();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "session-token";
|
||
return null;
|
||
});
|
||
await selectSpace("space-support");
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.api_base") throw new Error("device locked");
|
||
});
|
||
|
||
await expect(resetApiBase()).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not clear the custom server URL",
|
||
});
|
||
expect(currentApiBase()).toBe(previous);
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
authorization: "Bearer session-token",
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
|
||
vi.mocked(SecureStore.getItemAsync).mockReset();
|
||
vi.mocked(SecureStore.setItemAsync).mockReset();
|
||
vi.mocked(SecureStore.deleteItemAsync).mockReset();
|
||
await resetApiBase();
|
||
});
|
||
|
||
it("recovers a deleted-space fallback over a stale saved selection after restart", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id") throw new Error("device locked");
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(selectSpace("space-personal")).resolves.toBe(false);
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
// Stale deleted id is cleared even while the replacement write stays locked.
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }),
|
||
);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
|
||
it("clears a deleted selection when every SecureStore write fails after delete", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(selectSpace("space-personal")).resolves.toBe(false);
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
// Cleared selection lets startup fall through to the server default.
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
await expect(restartedApi.selectInitialSpace("space-personal")).resolves.toBe(true);
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
});
|
||
|
||
it("drops a stale deleted selection when rollback recovery cannot rewrite SPACE_KEY", async () => {
|
||
const storage = new Map<string, string>([
|
||
["rakazo.space_id", "space-deleted"],
|
||
[
|
||
"rakazo.space_rollback",
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }),
|
||
],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }),
|
||
);
|
||
});
|
||
|
||
it("recovers after restart when SecureStore could neither write nor clear after delete", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(selectSpace("space-personal")).resolves.toBe(false);
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-deleted");
|
||
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-deleted");
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(restartedApi.rpc("spaces/list")).resolves.toEqual({ spaces: [] });
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-deleted");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("does not let a stale rollback override a saved deleted-space fallback", async () => {
|
||
const apiBase = "http://127.0.0.1:3100";
|
||
const storage = new Map<string, string>([
|
||
["rakazo.space_id", "space-deleted"],
|
||
["rakazo.space_rollback", JSON.stringify({ apiBase, spaceId: "space-old" })],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
// clearStoredValue falls back to writing ""; keep that failing for rollback
|
||
// so only an overwrite of the rollback payload can neutralize it.
|
||
if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked");
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
// Neutralization overwrote the stale rollback; best-effort clear may leave
|
||
// the matching recovery payload when delete/empty writes stay locked.
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase, spaceId: "space-personal" }),
|
||
);
|
||
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-personal");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-personal");
|
||
});
|
||
|
||
it("does not write SPACE_KEY beside a stale rollback when neutralization fails", async () => {
|
||
const apiBase = "http://127.0.0.1:3100";
|
||
const storage = new Map<string, string>([
|
||
["rakazo.space_id", "space-deleted"],
|
||
["rakazo.space_rollback", JSON.stringify({ apiBase, spaceId: "space-old" })],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async () => {
|
||
throw new Error("device locked");
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
// Block recoverSpaceRollback during loadApiBase, and block SPACE_KEY clears.
|
||
// Writes of the new fallback id would succeed — the bug was doing that write
|
||
// before neutralization, then failing to undo it.
|
||
if (key === "rakazo.space_id" && (value === "" || value === "space-old")) {
|
||
throw new Error("device locked");
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-personal");
|
||
// Must not leave the new selection durable beside the stale rollback.
|
||
expect(storage.get("rakazo.space_id")).toBe("space-deleted");
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase, spaceId: "space-old" }),
|
||
);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_rollback") throw new Error("device locked");
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
// Restart may still recover the old rollback target, but must not have
|
||
// replaced a newer SPACE_KEY fallback with it.
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-old");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-old");
|
||
});
|
||
|
||
it("keeps the Space selection when unauthorized is a session failure", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "expired-token";
|
||
return storage.get(key) ?? null;
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("spaces/list")).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-support");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("keeps a Space selected during unauthorized recovery when the retry succeeds", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
let resolveRetry!: (value: Response) => void;
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveRetry = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = rpc("spaces/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||
await expect(selectSpace("space-new")).resolves.toBe(true);
|
||
resolveRetry(jsonResponse({ json: { spaces: [] } }));
|
||
|
||
await expect(pending).resolves.toEqual({ spaces: [] });
|
||
expect(selectedSpaceId()).toBe("space-new");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-new");
|
||
});
|
||
|
||
it("keeps a Space selected during unauthorized recovery when the retry fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
let resolveRetry!: (value: Response) => void;
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveRetry = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pending = rpc("spaces/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||
await expect(selectSpace("space-new")).resolves.toBe(true);
|
||
resolveRetry(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
|
||
await expect(pending).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBe("space-new");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-new");
|
||
});
|
||
|
||
it("ignores a 401 from a request sent before the user switched Spaces", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-a"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
|
||
let resolveStale!: (value: Response) => void;
|
||
const fetchMock = vi.fn().mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveStale = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const stale = rpc("bots/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-a");
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
resolveStale(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
|
||
// The stale request fails without probing or touching the new selection;
|
||
// Space B's own requests run recovery if B is itself inaccessible.
|
||
await expect(stale).rejects.toThrow("Unauthorized");
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(selectedSpaceId()).toBe("space-b");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-b");
|
||
});
|
||
|
||
it("ignores a stale 401 after switching away and back to the same Space", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-a"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
|
||
let resolveStale!: (value: Response) => void;
|
||
const fetchMock = vi.fn().mockImplementationOnce(
|
||
() =>
|
||
new Promise<Response>((resolve) => {
|
||
resolveStale = resolve;
|
||
}),
|
||
);
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const stale = rpc("bots/list");
|
||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-a");
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
await expect(selectSpace("space-a")).resolves.toBe(true);
|
||
resolveStale(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
|
||
// ID-only matching would treat this obsolete Space A response as current
|
||
// after A → B → A; the selection epoch must keep recovery from clearing
|
||
// the newer Space A selection.
|
||
await expect(stale).rejects.toThrow("Unauthorized");
|
||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-a");
|
||
});
|
||
|
||
it("heals durable divergence when persisting a new Space fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
await loadApiBase();
|
||
await expect(selectSpace("space-support")).resolves.toBe(true);
|
||
// A concurrent recovery persisted the claimed id, then the selection
|
||
// write below fails and rolls the claim back.
|
||
storage.set("rakazo.space_id", "space-new");
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-new") {
|
||
throw new Error("device locked");
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
|
||
await expect(selectSpace("space-new")).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
});
|
||
|
||
it("keeps a newer overlapping selection when an older persist fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
let rejectA!: (reason: Error) => void;
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-a") {
|
||
await new Promise<never>((_, reject) => {
|
||
rejectA = reject;
|
||
});
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const pendingA = selectSpace("space-a");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a"));
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
rejectA(new Error("device locked"));
|
||
|
||
await expect(pendingA).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-b");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-b");
|
||
});
|
||
|
||
it("converges durable state to the latest overlapping selection", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
let resolveA!: () => void;
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-a") {
|
||
await new Promise<void>((resolve) => {
|
||
resolveA = resolve;
|
||
});
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const pendingA = selectSpace("space-a");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a"));
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
// The older write lands stale after the newer one completed.
|
||
resolveA();
|
||
|
||
await expect(pendingA).resolves.toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-b");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-b");
|
||
});
|
||
|
||
it("keeps a later A claim when an earlier A→B→A persist fails", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
let firstAWriteCount = 0;
|
||
let rejectFirstA!: (reason: Error) => void;
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-a") {
|
||
firstAWriteCount += 1;
|
||
if (firstAWriteCount === 1) {
|
||
await new Promise<never>((_, reject) => {
|
||
rejectFirstA = reject;
|
||
});
|
||
}
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const pendingFirstA = selectSpace("space-a");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a"));
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
await expect(selectSpace("space-a")).resolves.toBe(true);
|
||
expect(storage.get("rakazo.space_id")).toBe("space-a");
|
||
rejectFirstA(new Error("device locked"));
|
||
|
||
await expect(pendingFirstA).resolves.toBe(false);
|
||
expect(selectedSpaceId()).toBe("space-a");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-a");
|
||
});
|
||
|
||
it("does not let auth cleanup overwrite a newer selection with a stale snapshot", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
let injected = false;
|
||
let holdCleanupReconcile = false;
|
||
let releaseCleanupWrite!: () => void;
|
||
const cleanupWriteHeld = new Promise<void>((resolve) => {
|
||
releaseCleanupWrite = resolve;
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id" && !injected) {
|
||
injected = true;
|
||
// Finish selecting B before cleanup snapshots it for reconcile.
|
||
await expect(selectSpace("space-b")).resolves.toBe(true);
|
||
holdCleanupReconcile = true;
|
||
}
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.space_id" && value === "space-b" && holdCleanupReconcile) {
|
||
holdCleanupReconcile = false;
|
||
// Hold only the post-clear reconcile write of the B snapshot.
|
||
await cleanupWriteHeld;
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
const pendingRpc = rpc("spaces/list");
|
||
await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-b"));
|
||
await expect(selectSpace("space-c")).resolves.toBe(true);
|
||
expect(storage.get("rakazo.space_id")).toBe("space-c");
|
||
releaseCleanupWrite();
|
||
|
||
await expect(pendingRpc).resolves.toEqual({ spaces: [] });
|
||
expect(selectedSpaceId()).toBe("space-c");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-c");
|
||
});
|
||
|
||
it("re-persists a Space selected while recovery cleanup is in flight", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
let injected = false;
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.space_id" && !injected) {
|
||
injected = true;
|
||
await expect(selectSpace("space-new")).resolves.toBe(true);
|
||
}
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("spaces/list")).resolves.toEqual({ spaces: [] });
|
||
expect(injected).toBe(true);
|
||
expect(selectedSpaceId()).toBe("space-new");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-new");
|
||
});
|
||
|
||
it("does not retry a mutating RPC against the default Space after Space auth failure", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-deleted"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("bots/create", { name: "Wrong space bot" })).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBeNull();
|
||
expect(storage.has("rakazo.space_id")).toBe(false);
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(String(fetchMock.mock.calls[0]![0])).toContain("/rpc/bots/create");
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-deleted");
|
||
expect(String(fetchMock.mock.calls[1]![0])).toContain("/rpc/spaces/list");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("keeps the Space selection when a mutating RPC unauthorized is a session failure", async () => {
|
||
const storage = new Map<string, string>([["rakazo.space_id", "space-support"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => {
|
||
if (key === "rakazo.session_token") return "expired-token";
|
||
return storage.get(key) ?? null;
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
await loadApiBase();
|
||
|
||
const fetchMock = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }))
|
||
.mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
|
||
await expect(rpc("bots/create", { name: "Should not land" })).rejects.toThrow("Unauthorized");
|
||
expect(selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||
expect(String(fetchMock.mock.calls[0]![0])).toContain("/rpc/bots/create");
|
||
expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-support");
|
||
expect(String(fetchMock.mock.calls[1]![0])).toContain("/rpc/spaces/list");
|
||
expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined();
|
||
});
|
||
|
||
it("recovers the active space after rollback persistence fails", async () => {
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
|
||
await loadApiBase();
|
||
await selectSpace("space-support");
|
||
|
||
const storage = new Map<string, string>();
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
if (key === "rakazo.api_base" || (key === "rakazo.space_id" && value === "space-support")) {
|
||
throw new Error("device locked");
|
||
}
|
||
storage.set(key, value);
|
||
});
|
||
|
||
await expect(saveApiBase("https://second-server.example")).resolves.toEqual({
|
||
ok: false,
|
||
error: "Could not save the server URL",
|
||
});
|
||
await expect(authHeaders()).resolves.toEqual({
|
||
"x-rakazo-space-id": "space-support",
|
||
});
|
||
expect(storage.get("rakazo.space_rollback")).toBe(
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-support" }),
|
||
);
|
||
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
await restartedApi.loadApiBase();
|
||
|
||
expect(restartedApi.selectedSpaceId()).toBe("space-support");
|
||
expect(storage.get("rakazo.space_id")).toBe("space-support");
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
|
||
it("does not recover a space on a different endpoint", async () => {
|
||
const storage = new Map([
|
||
["rakazo.api_base", "https://second-server.example"],
|
||
[
|
||
"rakazo.space_rollback",
|
||
JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-support" }),
|
||
],
|
||
]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
|
||
await expect(restartedApi.loadApiBase()).resolves.toBe("https://second-server.example");
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
|
||
it("removes a malformed space rollback record", async () => {
|
||
const storage = new Map([["rakazo.space_rollback", "null"]]);
|
||
vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null);
|
||
vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => {
|
||
storage.set(key, value);
|
||
});
|
||
vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => {
|
||
storage.delete(key);
|
||
});
|
||
vi.resetModules();
|
||
const restartedApi = await import("./api.js");
|
||
|
||
await restartedApi.loadApiBase();
|
||
expect(restartedApi.selectedSpaceId()).toBeNull();
|
||
expect(storage.has("rakazo.space_rollback")).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("mobile thread subscription", () => {
|
||
beforeEach(() => {
|
||
vi.restoreAllMocks();
|
||
vi.mocked(SecureStore.getItemAsync).mockReset();
|
||
vi.mocked(SecureStore.getItemAsync).mockResolvedValue("");
|
||
});
|
||
|
||
it("parses fragmented SSE frames and ignores malformed data and completion markers", async () => {
|
||
const encoder = new TextEncoder();
|
||
const stream = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(encoder.encode('data: {"json":{"type":"thread.pro'));
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
'gress","seq":4,"payload":{"delta":"Hi"}}}\n\ndata: not-json\n\ndata: [DONE]\n\n',
|
||
),
|
||
);
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
'data: {"json":{"type":"thread.message.created",\n' +
|
||
'data: "seq":5,"payload":{"messageId":"m1"}}}\n\n',
|
||
),
|
||
);
|
||
controller.close();
|
||
},
|
||
});
|
||
const fetchMock = vi.fn(async () => new Response(stream, { status: 200 }));
|
||
vi.stubGlobal("fetch", fetchMock);
|
||
const onEvent = vi.fn();
|
||
|
||
await subscribeThread({ botId: "bot-1" }, 3, onEvent, new AbortController().signal);
|
||
|
||
expect(fetchMock).toHaveBeenCalledWith(
|
||
"http://127.0.0.1:3100/rpc/threads/subscribe",
|
||
expect.objectContaining({
|
||
headers: expect.objectContaining({ accept: "text/event-stream" }),
|
||
body: JSON.stringify({ json: { botId: "bot-1", cursor: 3 } }),
|
||
}),
|
||
);
|
||
expect(onEvent).toHaveBeenCalledTimes(2);
|
||
expect(onEvent).toHaveBeenNthCalledWith(
|
||
1,
|
||
expect.objectContaining({ type: "thread.progress", seq: 4 }),
|
||
);
|
||
expect(onEvent).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.objectContaining({ type: "thread.message.created", seq: 5 }),
|
||
);
|
||
});
|
||
|
||
it("rejects responses that are unsuccessful or have no stream body", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(null, { status: 503 })),
|
||
);
|
||
await expect(
|
||
subscribeThread({ botId: "bot-1" }, -1, vi.fn(), new AbortController().signal),
|
||
).rejects.toThrow("rpc threads/subscribe failed (503)");
|
||
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn(async () => new Response(null, { status: 200 })),
|
||
);
|
||
await expect(
|
||
subscribeThread({ botId: "bot-1" }, -1, vi.fn(), new AbortController().signal),
|
||
).rejects.toThrow("rpc threads/subscribe failed (200)");
|
||
});
|
||
});
|
||
|
||
describe("mobile thread refresh targeting", () => {
|
||
it("drops a deferred group A refresh after navigation to group B", async () => {
|
||
let activeGroupId: string | undefined = "group-a";
|
||
let currentEpoch = 1;
|
||
let resolveRequest!: (snapshot: MobileSnapshot) => void;
|
||
const request = new Promise<MobileSnapshot>((resolve) => {
|
||
resolveRequest = resolve;
|
||
});
|
||
let applied: MobileSnapshot | null = null;
|
||
const refresh = request.then((snapshot) => {
|
||
if (
|
||
shouldApplyMobileThreadRefresh({
|
||
requestEpoch: 1,
|
||
currentEpoch,
|
||
targetBotId: undefined,
|
||
targetGroupId: "group-a",
|
||
activeBotId: undefined,
|
||
activeGroupId,
|
||
})
|
||
) {
|
||
applied = snapshot;
|
||
}
|
||
});
|
||
|
||
activeGroupId = "group-b";
|
||
currentEpoch += 1;
|
||
resolveRequest({
|
||
groupId: "group-a",
|
||
threadId: "thread-a",
|
||
messages: [],
|
||
olderCursor: null,
|
||
run: null,
|
||
});
|
||
await refresh;
|
||
|
||
expect(applied).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("mobile thread event reduction", () => {
|
||
it("appends an emoji reply with its exact target", () => {
|
||
const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "Done" }])]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
seq: 4,
|
||
payload: {
|
||
messageId: "reaction-1",
|
||
role: "user",
|
||
blocks: [{ kind: "text", text: "❤️" }],
|
||
replyToMessageId: "message-1",
|
||
},
|
||
});
|
||
|
||
expect(next?.messages.find((message) => message.id === "reaction-1")).toMatchObject({
|
||
role: "user",
|
||
blocks: [{ kind: "text", text: "❤️" }],
|
||
replyToMessageId: "message-1",
|
||
});
|
||
expect(next?.cursor).toBe(4);
|
||
});
|
||
|
||
it("appends a quoted reply carrying its excerpt", () => {
|
||
const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "Done" }])]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
seq: 4,
|
||
payload: {
|
||
messageId: "reply-1",
|
||
role: "user",
|
||
blocks: [{ kind: "text", text: "why this?" }],
|
||
replyToMessageId: "message-1",
|
||
replyQuote: "Done",
|
||
},
|
||
});
|
||
|
||
expect(next?.messages.find((message) => message.id === "reply-1")).toMatchObject({
|
||
role: "user",
|
||
replyToMessageId: "message-1",
|
||
replyQuote: "Done",
|
||
});
|
||
});
|
||
|
||
it("prepends ordered history pages without duplicating the boundary message", () => {
|
||
const initial = snapshot([mobileMessage("m-2", [], 2), mobileMessage("m-3", [], 3)], 2);
|
||
|
||
const next = prependMobileMessagePage(initial, {
|
||
threadId: "thread-1",
|
||
messages: [
|
||
mobileMessage("m-0", [], 0),
|
||
mobileMessage("m-1", [], 1),
|
||
mobileMessage("m-2", [], 2),
|
||
],
|
||
olderCursor: null,
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual(["m-0", "m-1", "m-2", "m-3"]);
|
||
expect(next?.olderCursor).toBeNull();
|
||
});
|
||
|
||
it("retains loaded history across refresh while dropping stale progress", () => {
|
||
const initial = snapshot(
|
||
[
|
||
mobileMessage("m-0", [], 0),
|
||
mobileMessage("m-1", [], 1),
|
||
mobileMessage("progress:run-1", [{ kind: "progress", text: "draft" }], 8),
|
||
],
|
||
null,
|
||
);
|
||
const refreshed = snapshot([mobileMessage("m-1", [], 1), mobileMessage("m-2", [], 2)], 1);
|
||
|
||
const next = mergeMobileSnapshot(initial, refreshed, true);
|
||
|
||
expect(next.messages.map((item) => item.id)).toEqual(["m-0", "m-1", "m-2"]);
|
||
expect(next.olderCursor).toBeNull();
|
||
});
|
||
|
||
it("accumulates progress deltas for the same run", () => {
|
||
const first = applyMobileThreadEvent(snapshot(), {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { delta: "Hel" },
|
||
});
|
||
const second = applyMobileThreadEvent(first, {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { delta: "lo" },
|
||
});
|
||
|
||
expect(second?.messages).toEqual([
|
||
{
|
||
id: "progress:run-1",
|
||
role: "bot",
|
||
runId: "run-1",
|
||
blocks: [{ kind: "progress", text: "Hello" }],
|
||
},
|
||
]);
|
||
});
|
||
|
||
it("preserves progress from a legacy run-only snapshot", () => {
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-legacy", [{ kind: "progress", text: "Still working" }]),
|
||
runId: "run-legacy",
|
||
},
|
||
]),
|
||
run: { id: "run-legacy", status: "running" },
|
||
activeRuns: undefined,
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.progress",
|
||
runId: "run-new",
|
||
payload: { text: "New work" },
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual([
|
||
"progress:run-legacy",
|
||
"progress:run-new",
|
||
]);
|
||
});
|
||
|
||
it("preserves concurrent progress when active-run metadata lags", () => {
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-concurrent", [
|
||
{ kind: "progress", text: "Concurrent work" },
|
||
]),
|
||
runId: "run-concurrent",
|
||
},
|
||
]),
|
||
run: { id: "run-current", status: "running" },
|
||
activeRuns: [{ id: "run-current", status: "running" }],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.progress",
|
||
runId: "run-current",
|
||
payload: { text: "Current work" },
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual([
|
||
"progress:run-concurrent",
|
||
"progress:run-current",
|
||
]);
|
||
});
|
||
|
||
it("holds live tool steps until mobile narration reaches a sentence boundary", () => {
|
||
const narration = applyMobileThreadEvent(snapshot(), {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { text: "Let me check " },
|
||
});
|
||
const pending = applyMobileThreadEvent(narration, {
|
||
type: "agent.tool.called",
|
||
runId: "run-1",
|
||
payload: { name: "SLACK_FIND_CHANNELS" },
|
||
});
|
||
const completed = applyMobileThreadEvent(pending, {
|
||
type: "thread.progress",
|
||
runId: "run-1",
|
||
payload: { delta: "now." },
|
||
});
|
||
|
||
expect(pending?.messages[0]?.blocks).toEqual([
|
||
{
|
||
kind: "progress",
|
||
text: "Let me check ",
|
||
pendingToolNames: ["SLACK_FIND_CHANNELS"],
|
||
},
|
||
]);
|
||
expect(completed?.messages[0]?.blocks).toEqual([
|
||
{ kind: "text", text: "Let me check now." },
|
||
{ kind: "steps", steps: [{ label: "Slack find channels", count: 1 }] },
|
||
]);
|
||
expect(blockText(completed?.messages[0] as MobileMessage)).toBe(
|
||
"Let me check now.\nSlack find channels",
|
||
);
|
||
});
|
||
|
||
it("formats channel messages with their platform attribution", () => {
|
||
expect(
|
||
blockText(
|
||
mobileMessage("channel-1", [
|
||
{
|
||
kind: "channel_message",
|
||
provider: "sendblue",
|
||
transport: "SMS",
|
||
channelId: "ch-1",
|
||
fromAddress: "+15551234567",
|
||
fromLabel: "Alex",
|
||
text: "Hello from the group",
|
||
},
|
||
]),
|
||
),
|
||
).toBe("SMS · Alex: Hello from the group");
|
||
expect(
|
||
blockText(
|
||
mobileMessage("channel-2", [
|
||
{
|
||
kind: "channel_message",
|
||
provider: "slack",
|
||
channelId: "ch-2",
|
||
fromAddress: "U123456",
|
||
fromLabel: "Alex",
|
||
text: "Hello from the group",
|
||
},
|
||
]),
|
||
),
|
||
).toBe("Slack · Alex: Hello from the group");
|
||
});
|
||
|
||
it("reads a choice card as its question and answer", () => {
|
||
const card = {
|
||
kind: "choice" as const,
|
||
step: "goal" as const,
|
||
question: "What’s one thing you want to get done in the next few weeks?",
|
||
textInput: { placeholder: "Your goal" },
|
||
options: [{ id: "skip", letter: "", label: "Skip" }],
|
||
};
|
||
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
|
||
expect(
|
||
blockText(
|
||
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
|
||
),
|
||
).toBe(`${card.question}\nRun a 10K`);
|
||
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
|
||
`${card.question}\nSkip`,
|
||
);
|
||
const note = {
|
||
kind: "intake_note" as const,
|
||
note: "done" as const,
|
||
nextCheckInAt: null,
|
||
text: "All set. I’m here whenever you need me.",
|
||
};
|
||
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
|
||
});
|
||
|
||
it("deduplicates durable messages and replaces matching transient subagent state", () => {
|
||
const initial = snapshot([
|
||
mobileMessage("message-1", [{ kind: "text", text: "old" }]),
|
||
mobileMessage("subagent:research", [
|
||
{
|
||
kind: "subagent",
|
||
agentId: "research",
|
||
name: "Research",
|
||
task: "Search",
|
||
status: "running",
|
||
},
|
||
]),
|
||
mobileMessage("subagent:other", [
|
||
{ kind: "subagent", agentId: "other", name: "Other", task: "Wait", status: "running" },
|
||
]),
|
||
{
|
||
...mobileMessage("progress:run-1", [{ kind: "progress", text: "draft" }]),
|
||
runId: "run-1",
|
||
},
|
||
]);
|
||
const completed = {
|
||
kind: "subagent" as const,
|
||
agentId: "research",
|
||
name: "Research",
|
||
task: "Search",
|
||
status: "completed" as const,
|
||
result: "Done",
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
id: "event-1",
|
||
type: "thread.message.created",
|
||
seq: 9,
|
||
runId: "run-1",
|
||
payload: { messageId: "message-1", role: "bot", blocks: [completed] },
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual(["message-1", "subagent:other"]);
|
||
expect(next?.messages[0]?.blocks).toEqual([completed]);
|
||
});
|
||
|
||
it("keeps a replayed bot-to-bot marker in its durable transcript position", () => {
|
||
const peerBlock = {
|
||
kind: "bot_message_received" as const,
|
||
fromBotId: "bot-peer",
|
||
fromBotName: "Peer",
|
||
text: "Please check this.",
|
||
};
|
||
const initial = snapshot([
|
||
mobileMessage("peer-message", [peerBlock], 1),
|
||
mobileMessage("newer-message", [{ kind: "text", text: "Working on it." }], 2),
|
||
]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
seq: 9,
|
||
payload: { messageId: "peer-message", role: "user", blocks: [peerBlock] },
|
||
});
|
||
|
||
expect(next?.messages.map((message) => message.id)).toEqual(["peer-message", "newer-message"]);
|
||
});
|
||
|
||
it("clears loaded history and active state when another client clears the thread", () => {
|
||
const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "old" }])], 1);
|
||
initial.run = { id: "run-1", status: "running" };
|
||
|
||
const next = applyMobileThreadEvent(initial, { type: "thread.cleared", seq: 12 });
|
||
|
||
expect(next).toMatchObject({ cursor: 12, messages: [], olderCursor: null, run: null });
|
||
});
|
||
|
||
it("applies the durable waiting-input run transition", () => {
|
||
const initial: MobileSnapshot = { ...snapshot(), run: { id: "run-1", status: "running" } };
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "run.waiting_input",
|
||
runId: "run-1",
|
||
seq: 8,
|
||
});
|
||
|
||
expect(waiting?.run?.status).toBe("waiting_input");
|
||
expect(waiting?.cursor).toBe(8);
|
||
const repeated = applyMobileThreadEvent(waiting, {
|
||
type: "run.waiting_input",
|
||
runId: "run-1",
|
||
seq: 9,
|
||
});
|
||
expect(repeated?.cursor).toBe(9);
|
||
expect(repeated?.run).toBe(waiting?.run);
|
||
});
|
||
|
||
it("applies computer takeover requests as waiting_takeover", () => {
|
||
const progress = mobileMessage("progress:run-1", [{ kind: "progress", text: "working…" }], 1);
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([progress]),
|
||
run: { id: "run-1", status: "running" },
|
||
activeRuns: [{ id: "run-1", status: "running" }],
|
||
};
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "computer.takeover.requested",
|
||
runId: "run-1",
|
||
seq: 10,
|
||
});
|
||
|
||
expect(waiting?.run?.status).toBe("waiting_takeover");
|
||
expect(waiting?.activeRuns?.[0]?.status).toBe("waiting_takeover");
|
||
expect(waiting?.messages.some((message) => message.id.startsWith("progress:"))).toBe(false);
|
||
expect(waiting?.cursor).toBe(10);
|
||
});
|
||
|
||
it("inserts a peer takeover run that was absent from the open snapshot", () => {
|
||
const progress = mobileMessage(
|
||
"progress:run-peer",
|
||
[{ kind: "progress", text: "working…" }],
|
||
1,
|
||
);
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([progress]),
|
||
run: { id: "run-user", status: "running" },
|
||
activeRuns: [{ id: "run-user", status: "running" }],
|
||
computer: {
|
||
state: "running",
|
||
controlHolder: "bot",
|
||
screenAvailable: true,
|
||
mode: "team",
|
||
busyBotName: "Peer",
|
||
},
|
||
};
|
||
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "computer.takeover.requested",
|
||
runId: "run-peer",
|
||
botId: "bot-peer",
|
||
seq: 12,
|
||
});
|
||
|
||
expect(waiting?.run).toEqual({ id: "run-peer", botId: "bot-peer", status: "waiting_takeover" });
|
||
expect(waiting?.activeRuns).toEqual([
|
||
{ id: "run-user", status: "running" },
|
||
{ id: "run-peer", botId: "bot-peer", status: "waiting_takeover" },
|
||
]);
|
||
expect(waiting?.messages.some((message) => message.id.startsWith("progress:"))).toBe(false);
|
||
expect(waiting?.computer?.busyBotName).toBeNull();
|
||
});
|
||
|
||
it("advances the cursor for durable message events", () => {
|
||
const next = applyMobileThreadEvent(snapshot(), {
|
||
type: "thread.message.created",
|
||
seq: 11,
|
||
payload: { messageId: "message-1", role: "bot", blocks: [{ kind: "text", text: "Done" }] },
|
||
});
|
||
|
||
expect(next?.cursor).toBe(11);
|
||
});
|
||
|
||
it("preserves ask actions and runId on created messages", () => {
|
||
const initial = snapshot();
|
||
const askBlock = {
|
||
kind: "ask",
|
||
text: "Review before writing",
|
||
detail: "title: Result",
|
||
status: "pending",
|
||
actions: [
|
||
{ id: "allow", label: "Allow once" },
|
||
{ id: "always", label: "Always allow" },
|
||
{ id: "deny", label: "Deny" },
|
||
],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.message.created",
|
||
runId: "run-1",
|
||
payload: { messageId: "message-ask", role: "bot", blocks: [askBlock] },
|
||
});
|
||
|
||
expect(next?.messages.at(-1)).toMatchObject({
|
||
id: "message-ask",
|
||
runId: "run-1",
|
||
blocks: [askBlock],
|
||
});
|
||
});
|
||
|
||
it("updates a waiting group run without replacing the newer active run", () => {
|
||
const initial: MobileSnapshot = {
|
||
...snapshot(),
|
||
run: { id: "run-newer", status: "running" },
|
||
activeRuns: [
|
||
{ id: "run-newer", status: "running" },
|
||
{ id: "run-waiting", status: "running" },
|
||
],
|
||
};
|
||
|
||
const waiting = applyMobileThreadEvent(initial, {
|
||
type: "run.waiting_input",
|
||
runId: "run-waiting",
|
||
});
|
||
|
||
expect(waiting?.run).toEqual({ id: "run-newer", status: "running" });
|
||
expect(waiting?.activeRuns).toEqual([
|
||
{ id: "run-newer", status: "running" },
|
||
{ id: "run-waiting", status: "waiting_input" },
|
||
]);
|
||
});
|
||
|
||
it("clears only the terminal run's live progress", () => {
|
||
const runA = { id: "run-a", status: "running" };
|
||
const runB = { id: "run-b", status: "running" };
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-a", [{ kind: "progress", text: "A" }]),
|
||
runId: runA.id,
|
||
},
|
||
{
|
||
...mobileMessage("progress:run-b", [{ kind: "progress", text: "B" }]),
|
||
runId: runB.id,
|
||
},
|
||
]),
|
||
run: runA,
|
||
activeRuns: [runA, runB],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "run.cancelled",
|
||
seq: 10,
|
||
runId: runA.id,
|
||
});
|
||
|
||
expect(next?.messages.map((item) => item.id)).toEqual(["progress:run-b"]);
|
||
expect(next?.run).toEqual(runB);
|
||
expect(next?.activeRuns).toEqual([runB]);
|
||
expect(next?.cursor).toBe(10);
|
||
});
|
||
|
||
it("keeps a failed member run's error while another member run is still active", () => {
|
||
const runA = { id: "run-a", status: "running" };
|
||
const runB = { id: "run-b", status: "running" };
|
||
const initial: MobileSnapshot = {
|
||
...snapshot([
|
||
{
|
||
...mobileMessage("progress:run-b", [{ kind: "progress", text: "B" }]),
|
||
runId: runB.id,
|
||
},
|
||
]),
|
||
run: runA,
|
||
activeRuns: [runA, runB],
|
||
};
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "run.failed",
|
||
seq: 11,
|
||
runId: runB.id,
|
||
payload: { error: "member exploded" },
|
||
});
|
||
|
||
expect(next?.activeRuns).toEqual([runA]);
|
||
expect(next?.run).toEqual({ id: runB.id, status: "failed", error: "member exploded" });
|
||
expect(next?.messages).toEqual([]);
|
||
});
|
||
|
||
it("updates a cloud agent card from thread.cloud_agent", () => {
|
||
const initial = snapshot([
|
||
mobileMessage("msg-ca", [
|
||
{
|
||
kind: "cloud_agent",
|
||
agentId: "ca-1",
|
||
title: "Add README",
|
||
status: "running",
|
||
url: "https://example.test/agents/ca-1",
|
||
},
|
||
]),
|
||
]);
|
||
|
||
const next = applyMobileThreadEvent(initial, {
|
||
type: "thread.cloud_agent",
|
||
seq: 9,
|
||
payload: {
|
||
messageId: "msg-ca",
|
||
agentId: "ca-1",
|
||
title: "Add README",
|
||
status: "finished",
|
||
url: "https://example.test/agents/ca-1",
|
||
branch: "cursor/add-readme",
|
||
prUrl: "https://github.com/example/repo/pull/1",
|
||
},
|
||
});
|
||
|
||
expect(next?.cursor).toBe(9);
|
||
expect(next?.messages[0]?.blocks[0]).toEqual({
|
||
kind: "cloud_agent",
|
||
agentId: "ca-1",
|
||
title: "Add README",
|
||
status: "finished",
|
||
url: "https://example.test/agents/ca-1",
|
||
branch: "cursor/add-readme",
|
||
prUrl: "https://github.com/example/repo/pull/1",
|
||
});
|
||
});
|
||
|
||
it("leaves the snapshot unchanged for unrelated events", () => {
|
||
const initial = snapshot();
|
||
expect(applyMobileThreadEvent(initial, { type: "run.started" })).toBe(initial);
|
||
expect(applyMobileThreadEvent(null, { type: "thread.progress" })).toBeNull();
|
||
});
|
||
});
|
||
|
||
function jsonResponse(body: unknown, init?: ResponseInit) {
|
||
return new Response(JSON.stringify(body), {
|
||
headers: { "content-type": "application/json" },
|
||
...init,
|
||
});
|
||
}
|
||
|
||
function snapshot(
|
||
messages: MobileMessage[] = [],
|
||
olderCursor: number | null = null,
|
||
): MobileSnapshot {
|
||
return {
|
||
botId: "bot-1",
|
||
threadId: "thread-1",
|
||
cursor: 3,
|
||
messages,
|
||
olderCursor,
|
||
run: null,
|
||
computer: {
|
||
state: "running",
|
||
controlHolder: "bot",
|
||
screenAvailable: true,
|
||
mode: "team",
|
||
busyBotName: null,
|
||
},
|
||
};
|
||
}
|
||
|
||
function mobileMessage(id: string, blocks: MobileMessage["blocks"], seq?: number): MobileMessage {
|
||
return { id, threadId: "thread-1", seq, role: "bot", blocks };
|
||
}
|
||
|
||
describe("mobile clipboard text", () => {
|
||
it("copies message content with transport labels and omits card chrome", async () => {
|
||
const { copyableMobileMessageText } = await import("./api");
|
||
expect(
|
||
copyableMobileMessageText({
|
||
id: "message",
|
||
role: "bot",
|
||
blocks: [
|
||
{ kind: "text", text: "Hello" },
|
||
{
|
||
kind: "channel_message",
|
||
provider: "sendblue",
|
||
transport: "SMS",
|
||
channelId: "ch-1",
|
||
fromAddress: "+15551234567",
|
||
fromLabel: "Sender",
|
||
text: "Reply",
|
||
},
|
||
{ kind: "card", lines: [] },
|
||
],
|
||
}),
|
||
).toBe("Hello\nSMS · Sender: Reply");
|
||
});
|
||
});
|